diff --git a/markup.py b/markup.py
new file mode 100755
index 0000000..6fe22c2
--- /dev/null
+++ b/markup.py
@@ -0,0 +1,271 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2022 Alek Ratzloff
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, version 3.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+import functools
+import random
+import re
+import string
+from typing import Protocol
+
+
+NAME_CHARS = string.ascii_letters + string.digits
+DIGIT_SET = set(string.digits)
+ESCAPES = {
+ "n": "\n",
+ "r": "\r",
+ "b": "\b",
+ "t": "\t",
+ "\\": "\\",
+ "'": "'",
+ '"': '"',
+}
+
+
+class MarkupCallable(Protocol):
+ """
+ Callable for macros.
+ """
+
+ def __call__(self, ctx: "MarkupContext", *args: str) -> str:
+ ...
+
+
+class MarkupContext:
+ def __init__(self):
+ self.macros: dict[str, MarkupCallable] = {}
+
+ def register_macro(self, name: str, callback: MarkupCallable):
+ self.macros[name] = callback
+
+ def get_macro(self, name: str) -> MarkupCallable:
+ if name not in self.macros:
+ raise Exception(f"Could not find macro with name `{name}`")
+ return self.macros[name]
+
+
+class MarkupExpr:
+ def render(self, ctx: MarkupContext) -> str:
+ return ""
+
+
+class MarkupRaw(MarkupExpr):
+ def __init__(self, text: str):
+ self.text = text
+
+ def render(self, ctx: MarkupContext) -> str:
+ return self.text
+
+
+class MarkupMacro(MarkupExpr):
+ def __init__(self, macro: str, args: list[MarkupExpr]):
+ self.macro = macro
+ self.args = args
+
+ def render(self, ctx: MarkupContext) -> str:
+ macro = ctx.get_macro(self.macro)
+ args = [arg.render(ctx) for arg in self.args]
+ return macro(ctx, *args)
+
+
+class MarkupParser:
+ def __init__(self, text: str):
+ self.text = text
+ self.pos = 0
+ self.parts: list[MarkupExpr] = []
+
+ @property
+ def c(self) -> str:
+ return self.ahead(1)
+
+ def render(self, ctx: MarkupContext) -> str:
+ self.parse()
+ return "".join([exp.render(ctx) for exp in self.parts])
+
+ def parse(self):
+ while self.c:
+ if self.c in r"\$":
+ mac = self.parse_macro()
+ self.parts += [mac]
+ else:
+ raw = self.parse_raw()
+ self.parts += [raw]
+
+ def parse_macro(self) -> MarkupExpr:
+ self.match("${")
+ self.skip_whitespace()
+ macro = self.parse_ident()
+ args = []
+ self.skip_whitespace()
+ while self.c and self.c != "}":
+ arg = self.parse_expr()
+ args += [arg]
+ self.skip_whitespace()
+ self.match("}")
+ return MarkupMacro(macro, args)
+
+ def parse_expr(self) -> MarkupExpr:
+ if self.c in ["\"", "\'"]:
+ return MarkupRaw(self.parse_macro_string())
+ elif self.c in NAME_CHARS:
+ return MarkupRaw(self.parse_ident())
+ elif self.is_match("${"):
+ return self.parse_macro()
+ else:
+ raise Exception(
+ "lost the thread around {self.text[self.pos:self.pos + 5]}..."
+ )
+
+ def parse_macro_string(self) -> str:
+ start = self.c
+ if self.c in ['"', "'"]:
+ self.adv()
+ else:
+ raise Exception("expected single quote (\") or double quote (')")
+ text = ""
+ while self.c and self.c != start:
+ if self.c == "\\":
+ self.adv()
+ if self.c in "nrbt\\'\"":
+ text += ESCAPES[self.c]
+ self.adv()
+ else:
+ raise Exception(f"unknown escape `\\{self.c}`")
+ else:
+ text += self.c
+ self.adv()
+ if not self.c:
+ raise Exception("unterminated string")
+ self.adv()
+ return text
+
+ def parse_ident(self) -> str:
+ start = self.pos
+ while self.c and self.c in NAME_CHARS:
+ self.adv()
+ return self.text[start : self.pos]
+
+ def skip_whitespace(self):
+ while self.c and self.c in string.whitespace:
+ self.adv()
+
+ def parse_raw(self) -> MarkupExpr:
+ text = ""
+ while self.c and self.c not in r"\$":
+ text += self.c
+ self.adv()
+ return MarkupRaw(text)
+
+ def ahead(self, count: int) -> str:
+ return self.text[self.pos : self.pos + count]
+
+ def adv(self, n: int = 1):
+ self.pos += n
+
+ def match(self, c: str):
+ if self.is_match(c):
+ self.adv(len(c))
+ else:
+ raise Exception(f"expected `{c}`")
+
+ def is_match(self, c: str) -> bool:
+ if c == self.ahead(len(c)):
+ return True
+ else:
+ return False
+
+
+################################################################################
+# Utility functions
+################################################################################
+
+
+def render(text: str, ctx: MarkupContext | None = None) -> str:
+ if not ctx:
+ ctx = default_context()
+ assert ctx
+ parser = MarkupParser(text)
+ return parser.render(ctx)
+
+
+################################################################################
+# Bulitin functions
+################################################################################
+
+
+def macro_random(
+ ctx: MarkupContext, first: str | None = None, second: str | None = None, *tail: str
+) -> str:
+ """
+ Choose a random number.
+
+ If no arguments, chooses a random float between 0.0 and 1.0, exclusive.
+
+ If one argument, chooses a random integer between 1 and the provided number, inclusive.
+
+ If two arguments, chooses a random integer between the provided numbers, inclusive.
+ """
+ if first and second:
+ lo = int(first)
+ hi = int(second)
+ return str(random.randint(lo, hi))
+ elif first:
+ hi = int(first)
+ return str(random.randint(1, hi))
+ else:
+ return str(random.random())
+
+
+def macro_choose(ctx: MarkupContext, *choices: str):
+ "Choose a random item."
+ return random.choice(choices)
+
+
+def macro_round(ctx: MarkupContext, n: str, digits: str | None = None) -> str:
+ "round() function, same as Python's."
+ if digits:
+ res = round(float(n), int(digits))
+ else:
+ res = round(float(n))
+ return str(res)
+
+
+@functools.cache
+def default_context():
+ c = MarkupContext()
+ c.register_macro("random", macro_random)
+ c.register_macro("choose", macro_choose)
+ c.register_macro("round", macro_round)
+
+ return c
+
+
+################################################################################
+# Main
+################################################################################
+if __name__ == '__main__':
+ import sys
+ filename = ''
+ if len(sys.argv) == 1:
+ text = sys.stdin.read()
+ else:
+ filename = sys.argv[1]
+ with open(filename) as fp:
+ text = fp.read()
+ try:
+ result = render(text)
+ print(result, end='')
+ except Exception as ex:
+ print(f"{filename}: {ex}")