Back to all posts
Header image, not needed to understand the article

Adding Tags to My Blog

Posted Dec 15, 2024 at 11:24 AM
Edited  Dec 17, 2024 at 8:43 PM


I decided that I wanted to start adding tags to my blog, as well as the ability to filter posts by these tags. I thought this would be fairly easy, but the problem ended up being a bit more interesting than I expected.

Part 1: I want to strangle past me

See, in order to add tags to posts I also need to add tags to drafts. Ok, easy enough I thought. I just need to create a table for tags in the database, and since this will be a many-to-many relationship I’ll just need to create an intermediate table called post_tags or something linking posts to tags. Except that past me was being super lazy when I added the ability to save posts as drafts and instead of doing the sensible thing and just adding a is_draft column in the post table of the database, I instead created a whole new draft table because I thought it would be easier. This meant that I’d have to create a draft_tags table as well and then I’d need to migrate entries from draft_tags to post_tags when I publish a draft.

an image of Homer Simpson strangling Bart. Homer is labeled “ME” and Bart is labeled “PAST ME”

This was unacceptable to me. So, I paid off that particular bit of technical debt by adding a is_draft column to the post table, setting it to false for all existing posts and true by default, then moving all the drafts to the post table.

Part 2: Actually starting the process of adding tags

With that out of the way, I now needed to actually begin adding tags. I decided to start by creating those tables I mentioned earlier. Here’s what those look like:

mysql> SHOW COLUMNS FROM tag;
+-------+--------------+------+-----+---------+----------------+
| Field | Type         | Null | Key | Default | Extra          |
+-------+--------------+------+-----+---------+----------------+
| id    | int          | NO   | PRI | NULL    | auto_increment |
| name  | varchar(200) | NO   |     | NULL    |                |
| color | char(7)      | YES  |     | NULL    |                |
+-------+--------------+------+-----+---------+----------------+

mysql> SHOW COLUMNS FROM post_tag;
+---------+------+------+-----+---------+----------------+
| Field   | Type | Null | Key | Default | Extra          |
+---------+------+------+-----+---------+----------------+
| id      | int  | NO   | PRI | NULL    | auto_increment |
| post_id | int  | YES  | MUL | NULL    |                |
| tag_id  | int  | YES  | MUL | NULL    |                |
+---------+------+------+-----+---------+----------------+

This is pretty much what you’d expect it to look like. The only thing that might need some explanation is that color column in tag. See, I had the idea to, through some hashing algorithm, automatically generate a color for each tag based on the name. Now you may ask “but Josh, if the color is generated based on the name, why do you need to store it in the database?” Well, I want to eventually add the ability to manually set this color if I don’t like the automatically generated one, so this column is more for future-proofing than anything. Apart from that this setup is very boring and obvious, so let’s get to the relatively more interesting parts.

Part 3: Working on the UI

Next I wanted to go to the opposite end of the back-end to front-end spectrum and work on the UI for adding and removing tags in my add/edit view. First I just added a little text box with an add button to the page with a little bit of javascript to take whatever was in the text box, put it in a tag element with a delete button attached to it, and clear the text box. I then found this nifty method of generating a color hex-code from a string via some bit manipulation magic on stackoverflow. I yoinked that and added a line in my function to set the background color of the generated tag element to whatever color is generated from the inputted tag name. So far this was going exactly as planned.

But then I noticed that for some inputs it could be hard to read the text on the tag element against the generated background color. To fix that, I needed to come up with some way of determining which text color to use (either black or white) for a given background color. What this problem amounts to is figuring out which text color maximizes the contrast against the background color. Lucky for me, there’s already a standardized way of determining the contrast between two colors. The W3C Web Content Accessibility Guidelines define the formula for determining the contrast ratio between two colors as:

(L1 + 0.05) / (L2 + 0.05)

Where L1 is the relative luminance of the lighter color, and L2 is the relative luminance of the darker color. Since black and white by definition have relative liminances of 0.0 and 1.0 respectively, we can easily plug those in and create a formula for determining which one will yield the highest contrast:

if (0.0 + 0.05) / (L + 0.05) > (L + 0.05) / (1.0 + 0.05) {
	// use white
} else {
	// use black
}

Where L is the relative luminance of the background color. This can be simplified down to:

if L > √(1.05 * 0.05) - 0.05 {
	[...]

Which can further be simplified (and approximated a bit) to:

if L > 0.179 {
	[...]

Now all we need to do is figure out how to get the relative luminance of the background color and we’re good to toss all this into a function that spits out the text color. Luckily, the W3C has us covered again. They give us a nice formula for calculating the relative luminance of any given color in the sRGB colorspace. Once that’s all in place, we get a function that looks a little like this:

const get_text_color = (c) => {
  // Break up the hex string into actual numbers representing 
  // the red, green, and blue components
  c = c.replace('#', '');
  let r = Number("0x"+c.substring(0, 2));
  let g = Number("0x"+c.substring(2, 4));
  let b = Number("0x"+c.substring(4));

  // Convert red, green, and blue components from 8bit RGB to sRGB
  // then convert them to the values needed for the final luminance calculation
  let convert = (v) => {
    v /= 255;

    v = v <= 0.03928 ? v/12.92 : ((v+0.055)/1.055)**2.4;

    return v;
  }

  r = convert(r);
  g = convert(g);
  b = convert(b);

  // Calculate luminance of the background color and compare
  // against the constant we calculated to determine which text color to return
  let L = 0.2126 * r + 0.7152 * g + 0.0722 * b;
  return L > 0.179 ? "#000000" : "#ffffff";
}

Whew! That was way more involved than I initially anticipated it being. But now with that squared away, I need to actually get this information into the HTML form. See, right now all I have is <span> elements which are not, in fact, form elements and won’t be sent back to the server as form data. Solving that is thankfully much simpler than figuring out the text color. All I have to do is a quick document.querySelectorAll to get all my little <span> tags, loop through them, and for each one create two hidden input fields: one for the name, and one for the color. Here’s what that actually ends up looking like for anyone interested (apologies for the rawdog javascript, I’m trying to see how far I can go without using a framework):

const add_tag_fields = () => {
  const tags = document.querySelectorAll("span.tag");
  
  const form = document.getElementById("addEditForm");

  tags.forEach((tag, i) => {
    let name = tag.innerHTML;
    let color = tag.style.getPropertyValue("background-color");
    color = color.split("(")[1].split(")")[0];
    parts = color.split(", ");
    parts = parts.map((v) => {
      v = parseInt(v).toString(16);
      return (v.length==1) ? "0"+v : v;
    });
    color = "#"+parts.join("");

    const nameInput = document.createElement("input");
    nameInput.type = "hidden";
    nameInput.value = name;
    nameInput.name = "Tags[" + i + "].Name";

    const colorInput = document.createElement("input");
    colorInput.type = "hidden";
    colorInput.value = color;
    colorInput.name = "Tags[" + i + "].Color";

    form.appendChild(nameInput);
    form.appendChild(colorInput);
  });
}

All I need to do now is call that function when the submit button is clicked and bada bing bada boom we’re sending tag data to the back-end on form submit.

Part 4: Handling this on the back-end

Previously, I wasn’t doing anything fancy with my form processing on the back-end because my forms themselves weren’t too fancy. There was a set number of fields that didn’t really need any type conversion. Because of that I was able to get away with just interacting with the request’s PostForm directly. Now, though, I can add any number of tags to a post, which means there can be any number of tag fields to process. I probably could have found a way to keep doing this, but I figured it was time to use a library here. So, I switched to using FormDecoder from go-playground/form. The benefit of this is that I no longer have to handle type conversions or anything and I get support for slices and arrays in my form struct.

Here’s the before and after:

Before:

type postForm struct {
	Title string
	URL   string
	Body  string
	validator.Validator
}

func (app *application) newPostSubmit(w http.ResponseWriter, r *http.Request) {
	err := r.ParseForm()
	if err != nil {
		app.clientError(w, http.StatusBadRequest)
		return
	}

	form := postForm{
		Title: r.PostForm.Get("title"),
		URL:   r.PostForm.Get("url"),
		Body:  r.PostForm.Get("body"),
	}

	asDraft, err := strconv.ParseBool(r.PostForm.Get("asDraft"))
	if err != nil {
		app.clientError(w, http.StatusBadRequest)
		return
	}

	// Code continues past here to form validation and database operations...
}

After:

type postForm struct {
	Title               string `form:"title"`
	URL                 string `form:"url"`
	Tags                []models.Tag
	Body                string `form:"body"`
	IsDraft             bool   `form:"asDraft"`
	validator.Validator `form:"-"`
}

func (app *application) newPostSubmit(w http.ResponseWriter, r *http.Request) {
	err := r.ParseForm()
	if err != nil {
		app.clientError(w, http.StatusBadRequest)
		return
	}

	var form postForm

	err = app.formDecoder.Decode(&form, r.PostForm)
	if err != nil {
		app.clientError(w, http.StatusBadRequest)
		return
	}

	// Code continues past here to form validation and database operations...
}

As you can see, that’s much easier than parsing a slice of unknown length manually. After that, I just needed to add some functions in the post model for handling tags and we’re good to go! Tags are now being stored in the database. Now I just need to actually make these things useful.

Part 5: Actually making these things useful

What do I mean by make them useful? Well at this stage of development all I could do was add tags to posts. The tags aren’t actually displayed on the posts anywhere, and you can’t actually use them to do any kind of filtering or anything yet.

Step one here is to get them to actually show up on posts. This prompted a little bit of refactoring on my part. I had gotten lazy when building out my HTML templates, and so everywhere on the site where you see a list of posts (the home page, the blog page, and my hidden draft list page) it was done using repeated code in the templates. To fix this, I just pulled it out into a partial and used that partial in place of the repeated code. Then I just had to add the tag list to that partial and the single page view and write some JavaScript to set the color (since inline CSS is a potential attack vector and thus I disallow it in my content security policy). I made sure the tags were actually links to the blog page with a query parameter to filter by the tag. We’ll be making use of that in just a bit. Here’s what the tag list looks like and the JavaScript to set the color properly:

<div class="field is-grouped is-grouped-multiline">
  {{range .Tags}}
  <div class="control">
    <a href="/blog?filter={{pathEscape .Name}}" class="tag {{.Color}} post-tag">{{.Name}}</a>
  </div>
  {{end}}
</div>
const touch_tags = () => {
  const tags = document.querySelectorAll("a.post-tag");

  tags.forEach((tag) => {
    let color = tag.className.split(' ')[1];
    let textColor = get_text_color(color);
    tag.style.setProperty("background-color", color);
    tag.style.setProperty("color", textColor);
  });
}

document.addEventListener("DOMContentLoaded", () => {
  touch_tags();
});

Next, I had to implement the filtering functionality. On the back-end, this was as simple as just parsing the filter query parameter and passing that to a function I wrote to only get posts with the desired tag:

handlers.go:
func (app *application) postList(w http.ResponseWriter, r *http.Request) {
	filter, err := url.PathUnescape(r.URL.Query().Get("filter"))
	if err != nil {
		app.serverError(w, r, err)
		return
	}

	var posts []models.Post
	if filter == "" {
		posts, err = app.posts.All()
	} else {
		posts, err = app.posts.Filter(false, filter)
	}
	if err != nil {
		app.serverError(w, r, err)
		return
	}
	[...]
	return app.render(w, r, http.StatusOk, "post_list.tmpl")
}

models/posts.go:
func (m *PostModel) Filter(isDraft bool, tagName string) ([]Post, error) {
	posts, err := m.All()
	if err != nil {
		return nil, err
	}

	t := Tag{Name: tagName}
	fPosts := []Post{}
	for _, p := range posts {
		if p.Tags.Contains(t) {
			fPosts = append(fPosts, p)
		}
	}

	return fPosts, nil
}

On the front-end things were a little more interesting. Basically, I had to pass all tags that were actually used on a non-draft post to the template, put those in a dropdown, and then write some JavaScript to handle the behavior of the filter button. Essentially, since I’m trying to push this as far as I can without a JavaScript framework, the filter button is actually a link to the blog page with the filter query parameter set to the selected tag’s name. Here’s what that looks like:

document.addEventListener("DOMContentLoaded", () => {
  const filterParam = new URLSearchParams(window.location.search).get('filter');
  const filterLink = document.getElementById("filterLink");
  const tagFilter = document.getElementById("tagFilter");
  let tagText = tagFilter.value;

  // If the selected tag is the same as the query parameter,
  // instead make the filter button a clear button
  if (decodeURIComponent(tagText) == filterParam) {
    filterLink.classList.remove("is-info");
    filterLink.classList.add("is-danger");
    filterLink.setAttribute("href", "/blog");
    filterLink.innerHTML = "Clear";
  }

  // When the tag selection is changed
  tagFilter.addEventListener("change", () => {
    // If the filter button is currently a clear button,
    // make it a filter button
    if (filterLink.classList.contains("is-danger")) {
      filterLink.classList.remove("is-danger");
      filterLink.classList.add("is-info");
      filterLink.innerHTML = "Filter";
    }
    
    // Set the link href appropriately
    tagText = tagFilter.value
    if (tagText) {
      filterLink.setAttribute("href", "/blog?filter="+tagText);
    } else {
      filterLink.setAttribute("href", "/blog");
    }
  });
});

After that, I’m done! The site now has tags that let you can filter posts by.

Conclusion

I won’t say this was difficult to add, but it absolutely took more work than I was expecting it to when I set out to implement this feature. I hope you found the process as interesting as I did.

Bonus Jewel:

Jewel the cat looking very cute