DnDHelp - Dungeons & Dragons spells and items help page

Let me tell you that playing Dungeon and Dragons is awesome. As a forever DM it is my job to know what spells are available for the players, what items will they find on adventures and what kind of monstrosities will they face. It is all great when you own books or you are proficient in finding the information online, with a one little but, as long as it is in English. I'm a proud owner of a few DnD books, some of them in English and some of them in Polish and over time Wizards of The Coast have been terminating the deals with Polish publishers making it harder and harder to get the book translated into my native language. With the new editions of DnD the only way of getting the books is getting them in English. It might not seem like an issue, if you can understand the language you can probably translate it on the fly, no big deal. Well when DMing a session, there is a ton of stuff that DM has to care about and adding one more brick to that is a lot to ask. Thus I decided to create a collection of staticaly served pages that would handle the translations for me, and on top of that would decrease the lookup time for a specific spell or item, typing into a search box is much quicker then browsing the entire book.

To start things off, I needed a static site generator, ideally in Python as it will need some tweaking to pull this off. I found pelican, it's customizable and the files can be kept in markdown which is just perfect as I'm using Obsidian for my notes and the DnD stuff. After just a few commands from the quickstart you are presented with a locally hosted page, noice.

Next, I needed all the spells available in DnD, easy enough there is a .csv that contains everything. As for now it's in english but we can work with that. Deepl offers a free tier API access for up to 1 million characters - that should be more then enough, I hope. The code effort is minimal as all that you do is just iterate over every record and translate that:

import csv
import deepl

API_KEY = "XXXXXXXXXXXX"
deepl_client = deepl.DeepLClient(API_KEY)

translated_data = []

with open("spells.csv", "r", encoding="utf-8") as file:
    reader = csv.reader(file)
    header = next(reader)  # skip the header

    for row in reader:
        translated_row = []
        for cell in row:
            if cell:
                result = translator.translate_text(cell, target_lang="pl")
                translated_row.append(result.text)
            else:
                translated_row.append(cell)
        translated_data.append(translated_row)

and with that the whole file is already translated. By the time I'm writing this article I cannot find the exact source but I've also enriched my .csv with the classes that could cast the spell.

After that I splitted each spell into separate files. Pelican uses file metadata, as a key value pairs between --- at the top of the file:

---
name: Alarm
lvl: 1
duration: 8 hours
...
---

It is fairly trivial so I won't go into that, but finally I had all the files that I needed to start generating the page. When I run pelican -r -l I should be able to access all my spells. Yeah, should be, there was nothing :) A few looks into the documentation and head scratches later I found that the usual blog post contains a specific set of fields: title, date and my field names are already translated into Polish (nazwa) and I don't even need the date.

Back to the drawing board... Inside the documentation you can find the Pelican internals, (should have been named "Pelican intestants") where a briefly explained library structure is found. In order for my spells to show I had to insert the title and the date into the notes.

from pelican.readers import BaseReader
import re


HEADER_RE = re.compile(
    r"\s*^---$"  # File starts with a line of "---" (preceeding blank lines accepted)
    r"(?P<metadata>.+?)"
    r"^(?:---|\.\.\.)$"  # metadata section ends with a line of "---" or "..."
    r"(?P<content>.*)",
    re.MULTILINE | re.DOTALL,
)


class DnDReader(BaseReader):
    enabled = True
    file_extensions = ["md"]

    def read(self, filename):
        with pelican_open(filename) as text:
            m = HEADER_RE.fullmatch(text)

        if not m:
            return super().read(filename)

        if not (content := m["content"]):
            if metadata.get("content", None):
                content = metadata["opis"]

        if metadata.get("date", None) is None:
            metadata["date"] = datetime(2023, 1, 1)

        if metadata.get("title", None) is None:
            metadata["title"] = metadata["nazwa"]

        return content, metadata

Just to make sure the metadata gets picked up correctly I added a regex that should find it. Then all that I'm doing is opening currently processed file, extract the metadata, and filling the missing fields based on already exisiting one or a static date, that won't be used anyway. It is also important to enable the newly created reader with these two lines outside of a class definition:

def add_reader(readers):
    readers.reader_classes["md"] = DnDReader

Now, running pelican -r -l I could navigate to localhost:8080/alarm and I saw the spell being displayed. It wasn't pretty, because everything was inside the metadata with custom naming you could only see the spell name and the static date, meaning it was time for the worst part of this whole initiative - CSS and HTML...

By playing with the custom reader, I knew that it was possible to specify the template for the article inside the metadata. Pelican uses templates in the similar fashion as Flask does, an html file with Jinja. With all my UI power, using bootstrap I managed to spit out this beauty:


{% extends "base.html" %} {% block title %}{{ article.nazwa }} {% endblock %}
{%block content %}
<div class="row justify-content-center">
  <div class="col-md-8 align-self-center d-flex justify-content-center pt-3 border-top">
    <div class="container">
      <div class="row">
        <h1 class="spell-name col d-flex justify-content-center">
          {{ article.nazwa }}
        </h1>
      </div>
      <div class="row">
        <div class="col"><strong>Szkoła: </strong>
          {{ article["szkoła"] }}</div>
        <div class="col">
          <strong>Krąg:</strong>
          {{ article["krąg"] }} {% if article["krąg"] == 0 %} (sztuczka){% endif %}
        </div>
      </div>
      <div class="row">
        <div class="col">
          <strong>Czas rzucania:</strong>
          {{ article["czas rzucania"] }} {% if article["rytuał"] %} (rytuał) {% endif %}
        </div>
        <div class="col"><strong>Zasięg: </strong>{{ article["zasięg"] }}</div>
      </div>
      <div class="row">
        <div class="col">
          <strong>Komponenty:</strong>
          <ul>
            {% for komponent in article["komponenty"] %}
            <li>{{ komponent }}</li>
            {% endfor %}
          </ul>
        </div>
        <div class="col">
          <strong>Czas trwania: </strong>{{ article["czas trwania"] }}
        </div>
      </div>
      <br />
      <div class="row mb-2">
        <div class="col">{{ article["opis"] }}</div>
      </div>
        </div>
      </div>
    </div>
  </div>
</div>
{% endblock %}

Basically all the metadata gets inserted into the correct spots.

Looking pretty decent, not gonna lie.

I mentioned earlier that for each spell I added a list of classes that can cast this spell and it is not yet included in the template bacause I want them to be clickable to redirect the user to a page that would display all the possible spells for a class. To achieve that I implemented a custom Tag (tags are the mechanism in Pelican that allows to reference other articles from each other):

class Tag(BaseTag):
    def __init__(self, name, *args, display_name=None, type=None, **kwargs):
        super().__init__(name, *args, **kwargs)
        self.display_name = display_name if display_name else name
        self.type = type

display_name allows me to customize the displayed name on the page and type helps to differentiate the tags between spells and items. On top of that I moved some of the previous metadata filling logic into the new function:

# inside DnDReader
    def parse_yaml(self, metadata, filename):
        meta = yaml.safe_load(metadata)
        f = Path(filename)
        if not any(x in f.parts for x in ("czary", "przedmioty")):
            return meta
        meta["title"] = meta["nazwa"]

        meta["template"] = "spell"
        meta["tags"] = [
            Tag(
                f"czar {x}",
                self.settings,
                display_name=x,
                type="class",
            )
            for x in meta["klasa"]
        ]
        meta["tags"].append(
            Tag(
                f'krąg {meta["krąg"]}',
                self.settings,
                display_name=f"Krąg {meta['krąg']}",
                type="circle",
            )
        )
        return meta

Although the metadata is in markdown it follows the yaml syntax and thus I am using yaml to load it from the text. After that tags are being appended to the metadata. First for the classes that can use the spell and at the end of the function, one more tag to the circle (or level) of the spell. Then finally adding some place in the template where the tags can be displayed:


        <div class="col">
          <strong>Krąg:</strong>
          <a href="tag/krag-{{ article['krąg'] }}.html">
            {{ article["krąg"] }} {% if article["krąg"] == 0 %} (sztuczka){% endif %}</a>
        </div>

...

      <div class="row">
        <div class="col d-flex justify-content-center">
          {% for tag in article.tags %}
          {% if tag.display_name in article["klasa"] %}
          <h3>
            <a href="{{ SITEURL }}/{{ tag.url }}" class="badge text-bg-light">{{ tag.display_name }}</a>
          </h3>
          &nbsp;
          {% endif %}
          {% endfor %}
        </div>
      </div>

And the resulting page looks as follows:

The final steps for the site included tedious CSS and HTML work which I won't go into, but I was able to create some additional pages, like listing spells for a certain classes or listing spells from a specific level. The items part of the website was a similar experience as I could treat them as spells, the only difference being that the level was the rarity of the item. After a while I discovered the search plugin that was fairly simple to include on the website that would work with some minimal config adjustments and voila! You can watch the final product here and the code here.

Thanks! Byeee