summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--wikimini/__init__.py500
-rw-r--r--wikimini/templates/__init__.py58
-rw-r--r--wikimini/templates/cite.py36
-rw-r--r--wikimini/templates/convert.py21
-rw-r--r--wikimini/templates/language.py19
-rw-r--r--wikimini/templates/mainlinks.py15
-rw-r--r--wikimini/templates/quotes.py27
-rw-r--r--wikimini/templates/various.py19
8 files changed, 427 insertions, 268 deletions
diff --git a/wikimini/__init__.py b/wikimini/__init__.py
index 397c539..b93ef81 100644
--- a/wikimini/__init__.py
+++ b/wikimini/__init__.py
@@ -4,282 +4,246 @@ import re
from tabulate import tabulate
-from typing import Union
+from typing import Union, Tuple
+#: The default API URL, pointing to the english Wikipedia.
API_URL = "https://en.wikipedia.org/w/api.php"
-# See https://pypi.org/project/tabulate/ for a reference of table formats.
-TABLEFMT = "fancy_grid"
-
-
-def page_url(title):
- return title.replace(" ", "_")
-
-
-def retrieve(title: str) -> (str, mwp.wikicode.Wikicode):
- params = {
- "action": "query",
- "prop": "revisions",
- "rvprop": "content",
- "rvslots": "main",
- "rvlimit": 1,
- "titles": title,
- "format": "json",
- "formatversion": "2",
- }
- headers = {"User-Agent": "Wikimini/1.0"}
- req = requests.get(API_URL, headers=headers, params=params)
- res = req.json()
- page = res["query"]["pages"][0]
- title = page["title"]
- revision = page["revisions"][0]
- text = revision["slots"]["main"]["content"]
- return (title, mwp.parse(text))
-
-
-def render_convert(obj: mwp.nodes.template.Template) -> str:
- """Renders the {{convert|...}} template."""
- if str(obj.params[1]) in {"-", "to"}:
- return "{0}{3} {1} {2}{3}".format(
- obj.params[0].value.strip_code(),
- obj.params[1].value.strip_code(),
- obj.params[2].value.strip_code(),
- obj.params[3].value.strip_code(),
- )
- return "{}{}".format(
- obj.params[0].value.strip_code(),
- obj.params[1].value.strip_code(),
- )
-
-
-def render_reign(obj: mwp.nodes.template.Template) -> str:
- """Renders the {{reign|...}} template."""
- if not obj.params:
- return "r. "
- first = obj.params[0].value.strip_code().strip() or "?"
- second = ""
- if len(obj.params) > 1:
- second = obj.params[1].value.strip_code().strip()
- return f"r. {first} – {second}"
-
-
-def render_cite_book(obj: mwp.nodes.template.Template) -> str:
- """Renders the {{cite book|...}} template."""
- title = obj.get("title", None)
- if title:
- title = title.value.strip_code().strip()
- else:
- title = "Untitled"
- names = []
- for idx in ["%", "%1", "%2", "%3", "%4", "%5", "editor1-%"]:
- last = obj.get(idx.replace("%", "last"), None)
- if last:
- last = last.value.strip_code().strip()
- first = obj.get(idx.replace("%", "first"), None)
- if first:
- first = first.value.strip_code().strip()
- if last and first:
- names.append(f"{last}, {first}")
- elif last:
- names.append(last)
- elif first:
- names.append(first)
- return "{} ({})".format(title, "; ".join(names))
-
-
-def render_main(obj: mwp.nodes.template.Template) -> str:
- """Renders the {{main|...}} template."""
- links = [
- "=> {} {}".format(page_url(str(t.value)), t.value) for t in obj.params
- ]
- return "Main articles:\n{}\n".format("\n".join(links))
-
-
-def render_quote(obj):
- text = obj.get("text", None)
- if not text:
- return ""
- content = _convert(text.value)
- lines = content.split("\n")
- return "\n".join(f"> {line}" for line in lines)
-
-
-def render_cquote(obj):
- text = obj.params[0]
- content = _convert(text.value)
- lines = content.split("\n")
- return "\n".join(f"> {line}" for line in lines)
-
-
-def _convert(obj: Union[mwp.nodes.Node, mwp.wikicode.Wikicode]) -> str:
- default = lambda obj: mwp.wikicode.Wikicode([obj]).strip_code(collapse=False)
- # This does the actual conversion
- if isinstance(obj, mwp.wikicode.Wikicode):
- converted = []
- iterator = iter(enumerate(obj.nodes))
- for i, node in iterator:
- # Pattern: * [[Wikilink]]\n
- if (i >= 2 and
- i + 1 < len(obj.nodes) and
- # Links can have a plural s after them
- re.match("s?\n", str(obj.nodes[i+1])) and
- isinstance(node, mwp.nodes.wikilink.Wikilink) and
- str(obj.nodes[i-1]) == " " and
- str(obj.nodes[i-2]) == "*"):
- converted.pop()
- converted.pop()
- _, after = next(iterator)
- converted.append("=> {} {}{}".format(
- page_url(str(node.title)),
- _convert(node),
- _convert(after),
- ))
- continue
- # Pattern: *[[Wikilink]]\n
- elif (i >= 1 and
- i + 1 < len(obj.nodes) and
- re.match("s?\n", str(obj.nodes[i+1])) and
- isinstance(node, mwp.nodes.wikilink.Wikilink) and
- str(obj.nodes[i-1]) == "*"):
- converted.pop()
- _, after = next(iterator)
- converted.append("=> {} {}{}".format(
- page_url(str(node.title)),
- _convert(node),
- _convert(after),
- ))
- continue
-
- # Default: Just convert the node
- converted.append(_convert(node))
- return "".join(converted)
- elif isinstance(obj, mwp.nodes.heading.Heading):
- return "{} {}\n".format("#" * min(obj.level, 3), obj.title.strip_code())
- elif isinstance(obj, mwp.nodes.tag.Tag):
- # Most tags are handled just fine and can be delegated to strip_code
- # (inline text styles), however we can do a bit better for list tags.
- if str(obj.wiki_markup) == "*":
- return "* "
- elif str(obj.wiki_markup) == "#":
- return "<!NUM!> "
- elif str(obj.tag) == "ref":
- return ""
- elif str(obj.tag) == "table":
- rows = []
- header = ()
- for row in obj.contents.nodes:
- if str(getattr(row, "tag", "")) != "tr":
+#: The default format for tables.
+#:
+#: See https://pypi.org/project/tabulate/ for a reference of table formats.
+TABLE_FORMAT = "fancy_grid"
+
+
+class Wikimini:
+ """The main object for Wikipedia/Gemini access and conversion.
+
+ Attributes:
+ api_url (str): The base URL of the API.
+ table_format (str): The name of the table style, see
+ :const:`TABLE_FORMAT`.
+ """
+ def __init__(self, api_url=API_URL, table_format=TABLE_FORMAT):
+ self.api_url = api_url
+ self.table_format = table_format
+
+ def page_url(self, title: str) -> str:
+ """Returns the link for page with the given title.
+
+ By default, this only replaces the special characters (like the space).
+ You can override this function if you need more fine-grained control
+ over how other Wikipedia articles are linked, e.g. if you pass them via
+ a query string or a special parameter.
+
+ Note that this function should only return the URL, not the full link
+ line.
+
+ Args:
+ title: Title of the page that should be linked.
+
+ Returns:
+ The link to the page.
+ """
+ return title.replace(" ", "_")
+
+ def retrieve(self, title: str) -> Tuple[str, mwp.wikicode.Wikicode]:
+ """Retrieves and parses the Wikipedia article with the given title.
+
+ This uses the API specified in :attr:`api_url` to get the Wikicode.
+
+ Args:
+ title: The title of the page to retrieve.
+
+ Returns:
+ A pair of page title and the parsed Wikicode.
+ """
+ params = {
+ "action": "query",
+ "prop": "revisions",
+ "rvprop": "content",
+ "rvslots": "main",
+ "rvlimit": 1,
+ "titles": title,
+ "format": "json",
+ "formatversion": "2",
+ }
+ headers = {"User-Agent": "Wikimini/1.0"}
+ req = requests.get(self.api_url, headers=headers, params=params)
+ res = req.json()
+ page = res["query"]["pages"][0]
+ title = page["title"]
+ revision = page["revisions"][0]
+ text = revision["slots"]["main"]["content"]
+ return (title, mwp.parse(text))
+
+ def _convert(self, obj):
+ """Function that does the actual conversion.
+
+ This is called recursively on each node, and should perform the correct
+ conversion - based on the node type.
+ """
+ default = lambda obj:\
+ mwp.wikicode.Wikicode([obj]).strip_code(collapse=False)
+
+ # This does the actual conversion
+ if isinstance(obj, mwp.wikicode.Wikicode):
+ converted = []
+ iterator = iter(enumerate(obj.nodes))
+ for i, node in iterator:
+ # Pattern: * [[Wikilink]]\n
+ if (i >= 2 and
+ i + 1 < len(obj.nodes) and
+ # Links can have a plural s after them
+ re.match("s?\n", str(obj.nodes[i+1])) and
+ isinstance(node, mwp.nodes.wikilink.Wikilink) and
+ str(obj.nodes[i-1]) == " " and
+ str(obj.nodes[i-2]) == "*"):
+ converted.pop()
+ converted.pop()
+ _, after = next(iterator)
+ converted.append("=> {} {}{}".format(
+ self.page_url(str(node.title)),
+ self._convert(node),
+ self._convert(after),
+ ))
+ continue
+ # Pattern: *[[Wikilink]]\n
+ elif (i >= 1 and
+ i + 1 < len(obj.nodes) and
+ re.match("s?\n", str(obj.nodes[i+1])) and
+ isinstance(node, mwp.nodes.wikilink.Wikilink) and
+ str(obj.nodes[i-1]) == "*"):
+ converted.pop()
+ _, after = next(iterator)
+ converted.append("=> {} {}{}".format(
+ self.page_url(str(node.title)),
+ self._convert(node),
+ self._convert(after),
+ ))
continue
- nodes = row.contents.nodes
- parsed = []
- row_is_header = False
- for node in nodes:
- if str(getattr(node, "tag", "")) not in {"td", "th"}:
+
+ # Default: Just convert the node
+ converted.append(self._convert(node))
+ return "".join(converted)
+ elif isinstance(obj, mwp.nodes.heading.Heading):
+ return "{} {}\n".format("#" * min(obj.level, 3), obj.title.strip_code())
+ elif isinstance(obj, mwp.nodes.tag.Tag):
+ # Most tags are handled just fine and can be delegated to strip_code
+ # (inline text styles), however we can do a bit better for list tags.
+ if str(obj.wiki_markup) == "*":
+ return "* "
+ elif str(obj.wiki_markup) == "#":
+ return "<!NUM!> "
+ elif str(obj.tag) == "ref":
+ return ""
+ elif str(obj.tag) == "table":
+ rows = []
+ header = ()
+ for row in obj.contents.nodes:
+ if str(getattr(row, "tag", "")) != "tr":
continue
- if str(node.tag) == "th":
- row_is_header = True
- parsed.append(_convert(node.contents).strip())
- if not row_is_header:
- rows.append(parsed)
- else:
- header = parsed
- return "\n```\n" + tabulate(rows, header, tablefmt=TABLEFMT) + "\n```\n"
- else:
- return default(obj)
- elif isinstance(obj, mwp.nodes.template.Template):
- # Most templates are handled fine (and completely stripped), however,
- # some of them are useful and provide some output that we should mimic
- # (for example, the convert template).
- name = str(obj.name).strip().lower()
- if name in {"cvt", "convert"}:
- return render_convert(obj)
- elif name in {"lang", "script"}:
- return _convert(obj.params[1].value)
- elif name == "lang-ar":
- return "Arabic {}/{}".format(
- _convert(obj.params[0].value),
- _convert(obj.params[1].value),
- )
- elif name in {"reign", "r.", "ruled", "rexit"}:
- return render_reign(obj)
- elif name in {"cite book", "cite journal", "cite news"}:
- return render_cite_book(obj)
- elif name.startswith("ipa"):
- return "pronounced [{}]".format(_convert(obj.params[0].value))
- elif name in {"main", "main article"}:
- return render_main(obj)
- elif name in {"quote", "blockquote"}:
- return render_quote(obj)
- elif name == "cquote":
- return render_cquote(obj)
+ nodes = row.contents.nodes
+ parsed = []
+ row_is_header = False
+ for node in nodes:
+ if str(getattr(node, "tag", "")) not in {"td", "th"}:
+ continue
+ if str(node.tag) == "th":
+ row_is_header = True
+ parsed.append(self._convert(node.contents).strip())
+ if not row_is_header:
+ rows.append(parsed)
+ else:
+ header = parsed
+ return "".join([
+ "\n```\n",
+ tabulate(rows, header, tablefmt=self.table_format),
+ "\n```\n",
+ ])
+ else:
+ return default(obj)
+ elif isinstance(obj, mwp.nodes.template.Template):
+ # Most templates are handled fine (and completely stripped), however,
+ # some of them are useful and provide some output that we should mimic
+ # (for example, the convert template).
+ name = str(obj.name)
+ template = templates.registry.get(name)
+ if template is None:
+ return default(obj)
+ else:
+ return template(self, obj)
+ elif isinstance(obj, mwp.nodes.wikilink.Wikilink):
+ if str(obj.title).startswith("File:") or str(obj.text).startswith("thumb|"):
+ return ""
+ elif str(obj.title).startswith("Category:"):
+ return ""
+ else:
+ return default(obj)
else:
return default(obj)
- elif isinstance(obj, mwp.nodes.wikilink.Wikilink):
- if str(obj.title).startswith("File:") or str(obj.text).startswith("thumb|"):
- return ""
- elif str(obj.title).startswith("Category:"):
- return ""
- else:
- return default(obj)
- else:
- return default(obj)
-
-def _postprocess(gemtext: str) -> str:
- # Strip out any more thumbs that have been left.
- # This happens because the wikilinks are nested in each other, which the
- # parser would only notice after doing the first replacement. We'll just
- # take the easy way out here and use a regex to get rid of them.
- gemtext = re.sub("^\\[\\[File:.*?\\]\\]$", "", gemtext, flags=re.MULTILINE)
-
- # Collapse too many empty lines
- while "\n\n\n" in gemtext:
- gemtext = gemtext.replace("\n\n\n", "\n\n")
-
- # Shortcut to avoid unnecessary splitting
- if "<!NUM!>" not in gemtext:
- return gemtext
-
- lines = gemtext.split("\n")
- counter = 1
- for idx in range(len(lines)):
- line = lines[idx]
- if line.startswith("<!NUM!>"):
- line = line.replace("<!NUM!>", str(counter), 1)
- lines[idx] = line
- counter += 1
- else:
- counter = 1
- return "\n".join(lines)
-
-
-def wikicode_to_gemtext(
- obj: Union[mwp.nodes.Node, mwp.wikicode.Wikicode]
- ) -> str:
- """Try to turn the given object into a sensible Gemtext representation.
- Note that wikicode is much more powerful than Gemtext, so this is a lossy
- function. The returned Gemtext tries to mimic the content of the Wikicode
- as much as possible (for human consumption).
-
- This function mostly mimics
- `~mwparserfromhell.wikicode.Wikicode.strip_code`, with some addition to
- better handle things that *can* be represented by Gemtext.
+ def _postprocess(self, gemtext):
+ # Strip out any more thumbs that have been left.
+ # This happens because the wikilinks are nested in each other, which the
+ # parser would only notice after doing the first replacement. We'll just
+ # take the easy way out here and use a regex to get rid of them.
+ gemtext = re.sub("^\\[\\[File:.*?\\]\\]$", "", gemtext, flags=re.MULTILINE)
+
+ # Collapse too many empty lines
+ while "\n\n\n" in gemtext:
+ gemtext = gemtext.replace("\n\n\n", "\n\n")
+
+ # Shortcut to avoid unnecessary splitting
+ if "<!NUM!>" not in gemtext:
+ return gemtext
+
+ lines = gemtext.split("\n")
+ counter = 1
+ for idx in range(len(lines)):
+ line = lines[idx]
+ if line.startswith("<!NUM!>"):
+ line = line.replace("<!NUM!>", str(counter), 1)
+ lines[idx] = line
+ counter += 1
+ else:
+ counter = 1
+ return "\n".join(lines)
+
+
+ def wikicode_to_gemtext(
+ self, obj: Union[mwp.nodes.Node, mwp.wikicode.Wikicode]
+ ) -> str:
+ """Try to turn the given object into a sensible Gemtext representation.
+
+ Note that wikicode is much more powerful than Gemtext, so this is a lossy
+ function. The returned Gemtext tries to mimic the content of the Wikicode
+ as much as possible (for human consumption).
+
+ This function mostly mimics
+ :meth:`~mwparserfromhell.wikicode.Wikicode.strip_code`, with some
+ additions to better handle things that *can* be represented by Gemtext.
+
+ Args:
+ obj: The object to convert.
+
+ Returns:
+ The converted Gemtext.
+ """
+ # Avoid calling str() on the whole Wikicode here
+ if (isinstance(obj, mwp.wikicode.Wikicode) and
+ str(mwp.wikicode.Wikicode(obj.nodes[:2])) == "#REDIRECT "):
+ title = str(obj.nodes[2].title)
+ if "#" in title:
+ title, section = title.split("#")
+ section = f"Section '{section}'"
+ else:
+ section = ""
+ return "Redirect:\n=> {} {}\n{}".format(
+ self.page_url(title), title, section
+ )
+ return self._postprocess(self._convert(obj))
- Args:
- obj: The object to convert.
- Returns:
- The converted Gemtext.
- """
- # Avoid calling str() on the whole Wikicode here
- if (isinstance(obj, mwp.wikicode.Wikicode) and
- str(mwp.wikicode.Wikicode(obj.nodes[:2])) == "#REDIRECT "):
- title = str(obj.nodes[2].title)
- if "#" in title:
- title, section = title.split("#")
- section = f"Section '{section}'"
- else:
- section = ""
- return "Redirect:\n=> {} {}\n{}".format(page_url(title), title, section)
- return _postprocess(_convert(obj))
+# import at the bottom to avoid circular dependencies
+from . import templates # pylint: disable=wrong-import-position
diff --git a/wikimini/templates/__init__.py b/wikimini/templates/__init__.py
new file mode 100644
index 0000000..9e983e1
--- /dev/null
+++ b/wikimini/templates/__init__.py
@@ -0,0 +1,58 @@
+"""Template substitution for Wikimini.
+
+This module contains functions that mimic Wikipedia's templates.
+
+A template is a function that takes the :class:`~wikimini.Wikimini` instance and the
+:class:`~mwparserfromhell.nodes.template.Template` node to convert, and returns
+a string with the template output (see :const:`Template`).
+"""
+from typing import Callable, Optional
+
+import mwparserfromhell as mwp
+
+from .. import Wikimini
+
+
+#: The type of a template render function.
+Template = Callable[[Wikimini, mwp.nodes.template.Template], str]
+
+
+class Registry:
+ """A container for all available templates."""
+ def __init__(self):
+ self.templates = {}
+
+ def get(self, name: str) -> Optional[Template]:
+ """Retrieves the template with the given name.
+
+ Args:
+ name: The name of the template.
+
+ Returns:
+ The template if found, or :any:`None`.
+ """
+ # Are templates case-sensitive?
+ # Yes, except usually the first letter.
+ # (https://en.wikipedia.org/wiki/Help:A_quick_guide_to_templates#FAQ)
+ template = self.templates.get(name)
+ if template is None:
+ template = self.templates.get(name[0].swapcase() + name[1:])
+ return template
+
+ def insert(self, name: str, template: Template):
+ """Insert the given template into the registry.
+
+ Args:
+ name: The name of the template.
+ template: The template to insert.
+ """
+ self.templates[name] = template
+
+
+#: The global template registry.
+registry = Registry()
+
+
+from . import ( # pylint: disable=wrong-import-position
+ convert, mainlinks, quotes, various, cite, language
+)
diff --git a/wikimini/templates/cite.py b/wikimini/templates/cite.py
new file mode 100644
index 0000000..ac4f597
--- /dev/null
+++ b/wikimini/templates/cite.py
@@ -0,0 +1,36 @@
+"""Citation related templates."""
+from . import registry
+
+
+def tmpl_citation(wikimini, obj):
+ """Renders the ``{{citation|...}}`` template."""
+ title = obj.get("title", None)
+ if title:
+ title = title.value.strip_code().strip()
+ else:
+ title = "Untitled"
+ names = []
+ for idx in ["%", "%1", "%2", "%3", "%4", "%5", "editor1-%"]:
+ last = obj.get(idx.replace("%", "last"), None)
+ if last:
+ last = last.value.strip_code().strip()
+ first = obj.get(idx.replace("%", "first"), None)
+ if first:
+ first = first.value.strip_code().strip()
+ if last and first:
+ names.append(f"{last}, {first}")
+ elif last:
+ names.append(last)
+ elif first:
+ names.append(first)
+ return "{} ({})".format(title, "; ".join(names))
+
+
+for name in ["cite", "citation", "cite arXiv", "cite AV media", "cite book",
+ "cite conference", "cite encyclopedia", "cite episode",
+ "cite interview", "cite journal", "cite magazine",
+ "cite mailing list", "cite map", "cite news", "cite newsgroup",
+ "cite podcast", "cite press release", "cite report",
+ "cite serial", "cite sign", "cite speech", "cite techreport",
+ "cite thesis", "cite web"]:
+ registry.insert(name, tmpl_citation)
diff --git a/wikimini/templates/convert.py b/wikimini/templates/convert.py
new file mode 100644
index 0000000..a7a3f44
--- /dev/null
+++ b/wikimini/templates/convert.py
@@ -0,0 +1,21 @@
+"""Implementations for the unit conversion templates."""
+from . import registry
+
+
+def tmpl_convert(wikimini, obj):
+ """Renders the ``{{convert|...}}`` template."""
+ if str(obj.params[1]) in {"-", "to"}:
+ return "{0}{3} {1} {2}{3}".format(
+ obj.params[0].value.strip_code(),
+ obj.params[1].value.strip_code(),
+ obj.params[2].value.strip_code(),
+ obj.params[3].value.strip_code(),
+ )
+ return "{}{}".format(
+ obj.params[0].value.strip_code(),
+ obj.params[1].value.strip_code(),
+ )
+
+
+registry.insert("convert", tmpl_convert)
+registry.insert("cvt", tmpl_convert)
diff --git a/wikimini/templates/language.py b/wikimini/templates/language.py
new file mode 100644
index 0000000..052b7f0
--- /dev/null
+++ b/wikimini/templates/language.py
@@ -0,0 +1,19 @@
+"""Language related templates."""
+from . import registry
+
+
+def tmpl_ipa(wikimini, obj):
+ """Renders the ``{{IPA|...}}`` template."""
+ return "pronounced [{}]".format(wikimini._convert(obj.params[0].value))
+
+
+registry.insert("IPA", tmpl_ipa)
+
+
+def tmpl_lang(wikimini, obj):
+ """Renders the ``{{Lang|...}}`` template."""
+ return wikimini._convert(obj.params[1].value)
+
+
+registry.insert("lang", tmpl_lang)
+registry.insert("script", tmpl_lang)
diff --git a/wikimini/templates/mainlinks.py b/wikimini/templates/mainlinks.py
new file mode 100644
index 0000000..ffcbc5e
--- /dev/null
+++ b/wikimini/templates/mainlinks.py
@@ -0,0 +1,15 @@
+"""Renders templates that link to further articles."""
+from . import registry
+
+
+def tmpl_main(wikimini, obj):
+ """Renders the ``{{main|...}}`` template."""
+ links = [
+ "=> {} {}".format(wikimini.page_url(str(t.value)), t.value)
+ for t in obj.params
+ ]
+ return "Main articles:\n{}\n".format("\n".join(links))
+
+
+registry.insert("main", tmpl_main)
+registry.insert("main article", tmpl_main)
diff --git a/wikimini/templates/quotes.py b/wikimini/templates/quotes.py
new file mode 100644
index 0000000..7c1429d
--- /dev/null
+++ b/wikimini/templates/quotes.py
@@ -0,0 +1,27 @@
+"""Renders various quote related templates."""
+from . import registry
+
+
+def tmpl_quote(wikimini, obj):
+ """Renders the ``{{blockquote|...}}`` template."""
+ text = obj.get("text", None)
+ if not text:
+ return ""
+ content = text.value.strip_code()
+ lines = content.split("\n")
+ return "\n".join(f"> {line}" for line in lines)
+
+
+registry.insert("blockquote", tmpl_quote)
+registry.insert("quote", tmpl_quote)
+
+
+def tmpl_cquote(wikimini, obj):
+ """Renders the ``{{cquote|...}}`` template."""
+ text = obj.params[0]
+ content = text.value.strip_code()
+ lines = content.split("\n")
+ return "\n".join(f"> {line}" for line in lines)
+
+
+registry.insert("cquote", tmpl_cquote)
diff --git a/wikimini/templates/various.py b/wikimini/templates/various.py
new file mode 100644
index 0000000..8c6e0d5
--- /dev/null
+++ b/wikimini/templates/various.py
@@ -0,0 +1,19 @@
+"""Various small templates."""
+from . import registry
+
+
+def tmpl_reign(wikimini, obj):
+ """Renders the ``{{reign|...}}`` template."""
+ if not obj.params:
+ return "r. "
+ first = obj.params[0].value.strip_code().strip() or "?"
+ second = ""
+ if len(obj.params) > 1:
+ second = obj.params[1].value.strip_code().strip()
+ return f"r. {first} – {second}"
+
+
+registry.insert("reign", tmpl_reign)
+registry.insert("ruled", tmpl_reign)
+registry.insert("rexit", tmpl_reign)
+registry.insert("r.", tmpl_reign)