Code Sidenotes - Tufte Style
I am a big fan of the Tufte styling scheme, refer to the HTML version for more details. If I wasn't flying blind on a hand-rolled blog generator that I haven't touched in 5 years, I would consider adding support for it on my blog. In any case, I have used this styling a few times and I think the side/margin approach for notes is better than footnotes in the digital media world.
Aside: this is my own version of sidenotes, if you have seen any of my older blogs. :shrug: Anyhow footnotes are great in printed material, where the page size is well within a human grasp at any time, but in e-media, those footnotes are usually several "screens" away. Sidenotes sidestep this problem.
One of the things I realize that the Tufte CSS project does not cover out of the box, is support for code annotations - a sort of margin notes for code, explaining some key decisions, or introducing a code to users etc.
Code, in HTML or in Markdown, is in its own world usually under <pre>
and <code>
tags. The standard CSS scheming under Tufte-CSS does not work, is not meant to work.
Here, I demonstrate a very hacky approach to apply Tufte margin notes to Code blocks. Imagine, we have the following code file test.py
:
def foo(a, b): """ This function adds 2 numbers.""" c = a + b return c
First, we need a way to capture the margin notes. I assume here that margin notes are in a separate file, with the extension .margin
. In this case, I create another file called test.py.margin
:
1: Function foo is very important. 1: It takes only 2 args 5: Remember to use c wisely
This example shows the expected margin note format. A simple line number followed by :
followed by the comment. Note how there are multiple notes for a single line and not all lines have a comment.
Aside: this is the simplest format I could come up with. However, it has its flaws: namely as the main file changes, the line numbers would go out of sync. Aside from putting comments in the main file, I see no way to manage sync of note locations with the actual code. But having comments in the code file simply defeats the purpose of having margin notes.
You may have a follow up question: why not simple notes in comments? But, then do we show those comments twice to the viewer: once on the main view and once again on the side bar? I guess you could have extra parsing and editing of the main file to remove comments which are actually side notes, but I went with the simplest option.
Now, for the main entry: the code to conver this code file and margin notes file into an html using Tufte CSS annotations to line everything up.
Normally, I prefer to put snippets like these into Git repos and share the link here, but in the age of LLMs, I am not making it easier for git hosting websites to take my code.
Aside: well, not that it much more difficult from here. In fact, I might be making things easier for LLM training by providing explanation and context. What do I do?
import sys class Node: def __init__(self, tag, attrs={}, text_content=""): self.tag = tag self.attrs = attrs self.text_content = text_content self.children = [] def add(self, child): self.children.append(child) return self def render(self): output = f"<{self.tag} " if self.attrs != None: for k, v in self.attrs.items(): output += f'{k}="{v}" ' output += ">" for c in self.children: output += c.render() output += self.text_content output += f"</{self.tag}>" return output def parse_margin_notes(mf): """ Returns a dict of <line-no>: [entries] """ notes = {} for line in mf: con = line.strip() lineno, dat = con.split(":") lineno = int(lineno) lnotes = notes.get(lineno, []) lnotes.append(dat) notes[lineno] = lnotes return notes def make_section(base_file, notes): """ Returns a Node object holding the section. """ section = Node("section") lineno = 1 nno = 1 for line in base_file: line = line.strip("\n") line = line.replace(' ', ' ') p = Node("p", {"style": "padding:0;margin:0;"}, line) if lineno in notes: for n in notes[lineno]: p.add(Node("label", {"for": f"mn-{nno}", "class": "margin-toggle"})) p.add(Node("input", {"type": "checkbox", "id": f"mn-{nno}", "class": "margin-toggle"})) p.add(Node("span", {"class": "marginnote"}, n)) nno += 1 section.add(p) lineno += 1 return section def convert(base_file, margin_file, out_file): bf = open(base_file, "r") mf = open(margin_file, "r") of = open(out_file, "w") mnotes = parse_margin_notes(mf) head = Node("head").add(Node("link", {"rel": "stylesheet", "href":"tufte.css"})) section = make_section(bf, mnotes) body = Node("body").add(Node("article").add(section)) of.write(head.render()) of.write(body.render()) inf = sys.argv[1] convert(inf, f"{inf}.margin", f"{inf}.html")
Some explanation is warranted:
- We do the whole thing in pure python with no external dependencies. This means, no Python to HTML conversion libraries.
- That said, the simple straightforward way of appending strings won't work. Managing html tags and attributes would become painful. The solution is very simple: a
Node
class which tracks children and arender
method which would simply recursively render children nodes between self's required tags. - Then we simply go over the input code line-by-line and convert each line into a
<p>
type, with extra markup for the lines with margin notes, to hold the notes using the HTML format documented on the "tufte-css" project. - Since we are using simple paras for each line, indentation does not come out of the box. Instead, we need to replace spaces with HTML compatible spaces.
I won't sugar coat it, the final product is not quite upto the mark, though it does do what we promised. The problem is twofold:
- Tufte-css's default font, which looks gorgeous for text, is not great for code - not monospace.
- There are no lines/number indicating the side notes.
- The whole process of storing comments in another file is icky, to say the least. Also, in the context of changing files, this seems like an impossible problem to solve. I don't see how we can make this better just yet.
I'll still look out for better ways to annotate code, this was a just quick exercise. See you later!