Context
Trying to think of a useful thing to put on my website, I realized that Youtube has been making their platform actively crappier in recent years. I saw someone talking about how you can't sort videos on channels by oldest anymore and so it makes it harder for people to discover things you've done on your channel in the past. I went about doing data entry to bring over the various playlists to the new video archive section of the website and decided that it would be nice if people could search for what they might be interested in on that page.
But I also wanted a project in typescript, so, here I am, trying to figure out how to get deno to compile Typescript to Javascript for me to use. Here's how I did it, as well as my notes on the process. I hope it's interesting or helpful for you!
The build system
After downloading deno release for windows and putting it on my path, I was able to successfully run the deno commands from my git bash shell without a problem. Next up, I got distracted for a bit with the neat focus editor and setting up a build system, which was really easy to do
This lets me press F5 and then the nice little build window pops up and runs the commands to check and then bundle the typescript into javascript:
That said, I'm still not totally used to Typescript and have the memory of a goldfish, I went ahead and decided to use Sublime Text 4 instead since I've used ST2 forever and the little snippet suggestions are super useful and I like the syntax highlighting. I'm not sure how to configure focus to highlight or if it even can, but anyway, sublime also supports "build systems" to do much the same thing.
While the documentation provides a lot of information about how to configure a build, you really just need this:
{ "shell_cmd": "deno bundle search.ts search.js" }
Within sublime use the tool menu, build system, and then new build system to open the file that needs the above code to be added to it.
Once you've done that, select the build you just made from the same menu as before and then you can press F7 or ctrl B to build:
Alright, now, for your build to actually work you need two things.
- a typescript file to compile
- a deno.json file
If you're like me, the first thing you did once you had deno was to run deno compile <file>
and then you end up with an exe to run. Very neat, but also very very useless when it comes to things that can
run in your browser. Maybe the most annoying thing, and the reason why I decided to put together this post in
the first place, is that the bundle command I'm using isn't actually documented in the main docs:
But, if you search for it you'll find it under the deprecated commands here. So, we probably shoudn't use it, but honestly, the suggested "fix" by the deprecation notice makes very little sense to me considering my understanding of typescript. Isn't the whole point to output Javascript? I suppose I must be misunderstanding deno's use case, because I thought it was just an easy to grab alternative to node.
But anyway, putting aside the fact that I should probably be using the tsc command instead for this, I don't want
to install node on my machine right now, so let's continue with our deprecated command instead since it makes it
easier. A quick little console.log type deal will work just fine as a test, but trying to do anything like that
or with the window
object will result in some really weird errors if you try to follow the documentation
for deno.
On the Deno page that says how to do type checks for browsers theres a snippet for a deno.json file:
{ "compilerOptions": { "lib": ["dom"] } }
But, if you use this you'll get this
Which is... very unhelpful and considering this is in the official docs under the heading about browsers. Confusing.
The section under the above mentions that if you want to target both the deno namespace and the browser, then
you want to add in deno.ns
to the list of compiler options and that will work.
The iterable libs I included were ones I found in this stackoverflow post and sounded like things I want because I assume I need them if I want to iterate over dom nodes. Anyway, with the build actually working, I can add in a script tag to the website I'm trying to help out with in question and lo and behold:
We've got it working! So, let's actually do the real work next.
The search data
Ideally, I don't want to repeat myself between what someone with javascript enabled will see and what someone without it would see. So I want the data itself to be part of the DOM. That's easy enough, for whatever reason, I saw someone using a description list element for this in the distance past, and now it's the first thing that comes to mind when I think of something with a title and a bunch of data. So:
<dl> <dt><h3>Doki Doki Literature Club</h3></dt> <dd> <img src="/images/video-archive/ddlc-thumbnail.jpg" /><br/> <time datetime="2017-12-03">Uploaded December 2017</time> <p> The reason I started streaming was because a friend wanted to see me react to this game. The very first streams weren't archived, but I did get Natsuki and Sayori's routes fully captured. Not to mention <a href="https://youtu.be/Ghw0JcP6MA8">voiced the entire game</a> with muscle-man voices to mess with one of my friends. </p> <a href="https://www.youtube.com/playlist?list=PLpwFqwKpNyvosMhFlPA8qRN2cI8cGSgb0">Click her for Playlist</a> </dd> </dl>
Will work to represent some basic data and display it in a way that's relatively ok to a user.
I can add in additional hidden tags if I want to add more things to search by, but a simple title search and sorting by latest or oldest are my first goal.
Since we're using typescript, the first thing that comes to mind for me is that I should have a class to represent what this HTML information is to the my program. Thinking for exactly 0.69ms, I named the class Entry and tossed in some properties:
class Entry { name: string; firstStreamed: Date; summary: string; category: string; elements: HTMLElement[]; parent: HTMLElement; constructor( name: string, firstStreamed: Date, summary: string, category: string, elements: Array<HTMLElement> ) { this.name = name; this.firstStreamed = firstStreamed; this.summary = summary; this.category = category; this.elements = elements; this.parent = notNull(notNull(elements.at(0)).parentElement); } }
Some of these we won't use for the first pass at this, but that's okay. We'll circle back. Before this code can make sense, I need to show you the helper method I kept abusing this whole time:
function notNull<T>(h: T | null | undefined): T { if (h === null || h === undefined) { throw new Error("The expected element does not exist"); } return h; }
I made this helper as a sort of assertion against the dom since I'm not writing this code directly into the html and if I'm dumb in the future and change things without realizing I need to update something, I think this error message will get me to the right place quickly when I need to debug.
Now, how do I actually make one of these entries? With this gross little static factory method:
static fromDTAndDD(dt: HTMLElement, dd: HTMLElement): Entry { const name = dt.textContent || ''; const yearMonthDay = notNull(dd.getElementsByTagName("time").item(0)?.dateTime); const summary = notNull(dd.getElementsByTagName("p").item(0)?.textContent); const section = notNull(dt.parentElement?.parentElement?.firstElementChild?.textContent); return new Entry(name, new Date(yearMonthDay), summary, section, [dt, dd]); }
The section
is the thing that feels the grossest to me, but I figured I could use
the fact that the whole DL is inside of a section, and each section starts with an h2 value
indicating whether the entry is part of the lets play list, or the multiplayer vods. The rest of
this is really just grabbing the elements, asserting nothing is missing, and then toss things along
to the creation method. There's probably some way to make this in a more typescript-y way, but I'm
just a noob, so this is fine.
Moving right along, we can build up our full list (and correct a view misspelled tags and missing end tags from my data entry binge) by iterating the two dom lists at the same time:
const searchable: Entry[] = []; const dtTags: HTMLCollection = notNull(document.getElementsByTagName('dt')); const ddTags: HTMLCollection = notNull(document.getElementsByTagName('dd')); for (let i = 0; i < dtTags.length; i++) { const dtTag = notNull(dtTags.item(i)) as HTMLElement; const ddTag = notNull(ddTags.item(i)) as HTMLElement; searchable.push(Entry.fromDTAndDD(dtTag, ddTag)); } const results: Entry[] = [];
I don't really like that I'm casting via as HTMLElement
but looking online
it doesn't seem like there's a particularly nice way of doing this that gets the types
I want without it, but it is what it is since it feels reasonably fine to assume that
if you ask the browse to get tags by the name of dt or dd, that they're going to be
HTMLElement types. So anyway, once we've got the searchable list of things, we can
decide how we'll actually find things!
So, onto our basic form:
I said basic and this as basic as it gets. We've got a search button, that admittedly probably doesn't need to exist considering I'm going to do everything on form change events, but anyway. I don't really like how the CSS from sakura is making the fieldset so big, but let's put that aside to avoid getting distracted.
<form id="searcharea"> <label> Find by title <input tabindex="1" id="textsearch" list="titles" /> </label> <fieldset> <legend>Sort by</legend> <label> Latest <input type="radio" name="sort" value="latest" checked /> </label> <label> Oldest <input type="radio" name="sort" value="oldest" /> </label> </fieldset> <datalist id="titles"></datalist> <input type="submit" id="search" value="Search" /> <input type="reset" value="Clear" /> </form>
We've got some IDs to fetch the elements via javascript, some basic radio button inputs and text inputs. We've also got an empty datalist, so let's fill that out first to take advantage of the browser's autocomplete for the input:
const titlesDatalist = notNull(document.getElementById('titles')); for (let i = 0; i < searchable.length; i++) { const option = document.createElement('option'); option.value = searchable[i].name; titlesDatalist.appendChild(option); }
Since we've already pulled out all the data into Entry
and then put
everything into the searchable
list, it makes it really easy to get
all the titles and generate the options to put them into the list.
Next, we'll setup some event handlers to call a stubbed method whenever we the user changes their mind on what they're searching by:
function calculateResults() { console.log('well do stuff here later'); } const textsearch = notNull(<HTMLInputElement>document.getElementById('textsearch')); textsearch.addEventListener('keyup', () => { calculateResults(); return false; }); const sortInputs = document.getElementsByName('sort'); for (let i = 0; i < sortInputs.length; i++) { sortInputs[i].addEventListener('change', () => { calculateResults(); return false; }); } const form = notNull(document.querySelector("form")); form.addEventListener('submit', (event) => { event.preventDefault(); calculateResults(); return false; }); form.addEventListener('reset', (event) => { setTimeout(() => { calculateResults(); }, 1); return true; })
Now whenever the user types into the form we'll try to calculate a result, if they change the sorting radio group buttons, if they clear the form, or if they press that lovely submit button I mentioned that no one probably ever will. Great!
The only thing worth mentioning here is the reset
handler. Specifically, that in order for
the form to actually be in a state of being reset, we have to set a timeout and do the calculation at that
point. When the reset handler is called, we haven't actually cleared the form yet. Which, honestly is a bit
odd since I'd expect it to fire after the reset, but I'm sure, just like all things in the browser,
there's a logical and reasonable explanation for why this behavior exists 1.
Before we go into the calcualte method, I just want to note that I added a p tag to the page with an id of noresults and wrote a simple little message to show to people if we get no results. The HTML is marked hidden by default, and if we've got javascript enabled, we'll end up showing it in the future via the search.
In order to calculate the result, we need to know if the search input matches the entry in our list. So, our match method can look something like this:
interface SearchFormElements extends HTMLFormControlsCollection { sort: RadioNodeList; textsearch: HTMLInputElement; } matches(searchFormElements: SearchFormElements) { const value = searchFormElements.textsearch.value?.trim()?.toLowerCase() || ''; const matchesName = this.name.toLowerCase().includes(value); return matchesName; }
This SearchFormElements
interface I've made is a little trick I found when I searched
online for ways to avoid having to type out form.elements.namedItem('name')
and then
cast the inputs as the specific types I needed. By extending like this, it makes it a lot simpler
for the rest of my code to see the RadioNodeList as a radio node list and not as a random Element
or HTMLElement type. For the time being, we'll do a case insensitive match on the name of each
entry, but we'll write the code ready for expansion by storing the booleans in their own const for
later.
Next up, I'll make two simpler helpers in preparation for the fact that once we call that match method, we're probably going to want to hide or show the elements that are part of the entry:
hide() { this.elements.forEach((value) => value.hidden = true) } show() { this.elements.forEach((value) => value.hidden = false) }
And with that done, we're ready to write the calculate method. Using the form
const we declared when we attached the event we can cast its elements as our interface and
then proceed to iterate, match, and toggle visibility:
const noResults = notNull(document.getElementById('noresults')); function calculateResults() { const searchFormElements = form.elements as SearchFormElements; const sortValue = searchFormElements.sort.value; results.splice(0, results.length); for (let i = 0; i < searchable.length; i++) { if (searchable[i].matches(searchFormElements)) { results.push(searchable[i]); searchable[i].show(); } else { searchable[i].hide(); } } results.sort((a: Entry, b: Entry) => { if (sortValue == 'latest') { return b.firstStreamed.getTime() - a.firstStreamed.getTime(); } else { return a.firstStreamed.getTime() - b.firstStreamed.getTime(); } }) results.forEach((entry) => { entry.elements.forEach((htmlElement) => { entry.parent.appendChild(htmlElement); }); }); noResults.hidden = results.length !== 0; };
For any of the values that are matching, we sort them based on the sort by value
strategy, and then proceed to reorder the results. The re-ordering of the elements
on the page is done via appendChild
which, somewhat confusingly to
its name, does the following:
From the documentation
Note: If the given child is a reference to an existing node in the document, appendChild() moves it from its current position to the new position.
By keeping a reference to the parent element, when we re-append it to the DOM, we'll end up moving the element into the appropriately sorted position without the browser having to create a brand new node to insert or anything like that. This is also pretty dang fast when I test it, possibly because the list of entries gets small pretty quickly:
So this works. But it's also a bit jarring just how quickly it is I suppose. Users are probably used to things being slow, and not instant when it comes to stuff like this. So, let's make them feel slower and use a css transition:
<style> dt,dd { animation-duration: 0.45s; animation-name: slidein; } @keyframes slidein { from { opacity: 0; } to { opacity: 100; } } </style>
I experimented with transitional slides and similar while looking at MDN documentation, but ultimately just decided that the fade in with opacity is simpler to deal with and looks ok. With that done, the whole search experience works, even though it's not that great yet. If you type in something like "Zelda" then you'll get 3 entries right now, but they're not sorted by relevance at all. The only problem of course is that its kind of hard to do two dimensions for sorting system like this when our data is too granular.
While we could define a score method and use the name and summary text to try to find something. The problem we run into is that you can't sort something by relevance, and _then_ by latest/oldest and expect to get a good result. Or at least, not with this particular dataset. I have 93 entries, so once someone searches by name they're going to have a lot less things. Even for the yakuza streams, which would give back a handful of entries, sorting that by some score is also going to just filter them down by names right now due to the hide on match behavior we have. I don't really want to not hide things right now, and tuning a score and then having the hiding behavior occur relating to that sounds like a whole lot more work than what we need. So. Let's call it a day. You can check out the results over on the vod archive page if you'd like. If this was useful or interesting to you, stop by the stream and say hi or leave me a youtube comment if I played some obscure game you like!