How do we modify Sublime Text? ↩
Extending Sublime Text can be done with Packages. I've only ever used a few of these to be honest. Simple things like some commands to prettify/minify JSON or XML were all I ever needed. The build system is already pretty customizable if you want to use it, and the only thing I installed in the last year was the LSP package, which I have disabled right now because I found it to be intrusive and I prefer code snippets instead 2. So, the next obvious question becomes: how do we build a package ourselves?
Well, checking in on the office website docs page shows that we've got a whole section called "Package Development". Seems like a good place to start.

I'm not making a theme, or trying to tweak colors, so the first two are out. Am I making a menu? I don't think so? Looking at that page, the example given is for the File menu itself. That's not what I want, and while it does list off some information about a "context menu for text areas" I'm not sure if that's actually an autocomplete pop up like what I'm thinking of.
The API Environments page is full of useful information we will need. Like the python version that plugins run with, which standard libraries are available to us to use... all that good stuff. But, that doesn't really tell me how to make one. I doubt I'm going to find this if I look into anything beneath the syntax definition link.
So that just leaves the API Reference page. Which, thankfully has a very obvious header for us to zoom into:

Cool, so this page has some example plugins that we can use as a reference and also lists off all the potential internal types from Sublime that will be available to us when we write our code. Sounds like we've got everything we need, so let's go figure out how these things work.
Understanding the examples ↩
The example packages that are noted are said to reside in Packages/Default/ and the website assumes you know where that is. If you naively think that this menu is going to help you

Then you'll swiftly find out you're wrong. This menu opens up the SublimeTextInstallDir/Data/Packages
packages folder. Which will have a single User
folder, while this is where our custom package will
eventually live, it's not where the built in ones are. For those we have to pop up the next level or two where
we'll find

The SublimeTextInstallDir/Packages folder where we can spot the Default.sublime-package file.
That extension isn't doing you any favors. Sublime doesn't offer itsef as a way to open it. Rather, the secret passphrase is noted by the plugin porting guide that I happened to read while searching for the example plugins themselves. These are just zip files, and so, trust old 7-zip, winrar, or whatever your unzipper of choice is can extract the file and get at the guts:

And so now we can take a look at not only what the website recommended for examples, but also at a ton of the built in internals of Sublime itself! Which, no doubt, will be very useful for what we're up to today. The first suggestion, exec.py, is interesting, but doesn't relate to tweaking text at all, so I'm less interested. Font.py doesn't exist in ST4 it seems, but while looking for it I did spot the copy_path.py file which includs the implementation of the ever useful CopyPathCommand:
import sublime import sublime_plugin class CopyPathCommand(sublime_plugin.WindowCommand): def run(self): sheet = self.window.active_sheet() if sheet and len(sheet.file_name()) > 0: sublime.set_clipboard(sheet.file_name()) sublime.status_message("Copied file path") def is_enabled(self): sheet = self.window.active_sheet() return sheet and sheet.file_name() is not None and len(sheet.file_name()) >
While this doesn't tell me how to do any sort of autocomplete, I think it's valuable to note that if I want the current
file, then I can use sheet.file_name()
to get that. It's also very bitesize, so it makes it easier to
understand at a glance that this command is made by subclassing from the sublime plugin type and overriding. Which is
something that wasn't super obvious from the previous exec example mentioned first. That file has its own set
of helpers and the only subclassing happens on lines 164 and 485, so it's easy to miss if you're just skimming like we
are right now.
goto_line.py is also of interest to us, this is the handy command one uses all time (or at least I do)
to jump directly to a single line via typing ctrl+P :linenumber
and pressing enter.
class PromptGotoLineCommand(sublime_plugin.WindowCommand): def run(self): self.window.show_input_panel("Goto Line:", "", self.on_done, None, None) def on_done(self, text): try: line = int(text) if self.window.active_view(): self.window.active_view().run_command("goto_line", {"line": line}) except ValueError: pass class GotoLineCommand(sublime_plugin.TextCommand): def run(self, edit, line): # Convert from 1 based to a 0 based line number line = int(line) - 1 # Negative line numbers count from the end of the buffer if line < 0: lines, _ = self.view.rowcol(self.view.size()) line = lines + line + 1 pt = self.view.text_point(line, 0) self.view.sel().clear() self.view.sel().add(sublime.Region(pt)) self.view.show(pt)
Or least, that's what I think it does. I'm not 100% sure because this code implies that I can use negative line
numbers, which I've never had to do in my life, but also, something which didn't work when I just tried it. Putting
aside that oddity, I think the references to view
are worth understanding at least. According to the
Types part of the documentation page:
Represents a view into a text Buffer. Note that multiple views may refer to the same Buffer, but they have their own unique selection and geometry. A list of these may be gotten using View.clones() or Buffer.views().
This sounds like it will probably be useful later. Moving right along, mark.py is interesting since it shows how to create a selection region based on multiple marks, but it doesn't seem to give any clues on what we're trying to accomplish. show_scope_name.py on the other hand:
import os import sublime import sublime_plugin def copy(view, text): sublime.set_clipboard(text) view.hide_popup() sublime.status_message('Scope name copied to clipboard') class ShowScopeNameCommand(sublime_plugin.TextCommand): def run(self, edit): scope = self.view.scope_name(self.view.sel()[-1].b).rstrip() scope_list = scope.replace(' ', '<br>') stack = self.view.context_backtrace(self.view.sel()[-1].b) backtrace = '' digits_len = 1 for i, frame in enumerate(reversed(stack)): ...
Scope... well, that VS Code optimization article I linked above talked about the scopes being a way that the editors can add meaning and context to what some bit of text is. So, what happens if I run this command to show scope from the place where I'd want to be taking any sort of action?

Oh ho ho!

This looks like exactly what we need to be able to tell if we're inside of an attribute or not! I was thinking that we'd have to do something like listen for keystrokes or movement, then do something like keep track of the last few characters near the cursor and bail out early and often for not finding an href or src nearby. But this? This is way better! We've got half of the puzzle already, now we just need to figure out how the autocomplete sort of stuff and suggestions trigger, because then we could probably hook in those!
So looking at the python code, I omitted a bunch of the code handling the HTML formatting for that window that's displayed,
but most importantly that list of scopes and contexts is from that stack
and scope_list
. So we can
definitely use this information to our advantage and fetch it out in a similar way when we write our code for this. Heck, the
way that the code tells me how to make a pop up is also pretty handy I think:
class ShowScopeNameCommand(sublime_plugin.TextCommand): def run(self, edit): scope = self.view.scope_name(self.view.sel()[-1].b).rstrip() scope_list = scope.replace(' ', '<br>') stack = self.view.context_backtrace(self.view.sel()[-1].b) ... self.view.show_popup(html, max_width=512, max_height=512, on_navigate=self.on_navigate) def on_navigate(self, link): if link.startswith('o:'): ... else: copy(self.view, link[2:])
I'm not sure if I'll have to make any sort of popup on my own for autocompleting, but it's good to know how to do it just in case. The last example plugin mentioned, arithmetic.py, seems pretty useless and doesn't show us anything new. But, we've got a number of other commands in this unzipped Default package to explore, so no doubt there will be something in here that will catch my eye 3. Or so I thought, but after a bit more searching I didn't turn up anything and so I instead popped over to the API reference and did a quick search:

Well look at that. These handy functions are both inside of the ViewEventListener
class, which:
A class that provides similar event handling to EventListener, but bound to a specific view. Provides class method-based filtering to control what views objects are created for.
So it sounds like I could potentially use EventListener
or ViewEventListener
, that note
about the filtering sounds like it would be good though. Doing a quick search in the Default package folder I only
found one:
class EditSettingsListener(sublime_plugin.ViewEventListener): """ Closes the base and user settings files together, and then closes the window if no other views are opened """ @classmethod def is_applicable(cls, settings): return settings.get('edit_settings_view') is not None
So it looks like the is_applicable
method is used to narrow the scope of the event listener. So that'll
probably be pretty handy I think. So, I think that we've basically got all the knowledge we need to start prototyping.
Which leads us to the first question of our development cycle!
Prototyping our plugin ↩
With our research done, the question becomes, how do we actually get started on coding? while I was trying to track down the package location and how to run the scope command, I found the developer menu which has exactly what we need:

Pressing the new plugin will populate an empty buffer with some basic boilerplate:
import sublime import sublime_plugin class ExampleCommand(sublime_plugin.TextCommand): def run(self, edit): self.view.insert(edit, 0, "Hello, World!")
And the documentation on packages tells us where to place
it and how naming and location impact the merger of this and any other packages we have running. So, we can save our
fancy new file into a new folder in our packages directory then, I guess as a .py file since that seems to be what the
Default package does. For good measure, let's include a .python-version file with 3.8
in it so that
that's clear. Both of these files are saved into SublimeRoot/Data/Packages and then when I go to
package control and check out the enable packages option I see:

Great. So now our code should load and be runnable. But, the example command is useless? Like, there's no way to run it since there's no bindings to a key or anything like that. So, let's tweak this command a bit into something that we can confirm is working with what we explored before. First off, let's rename the class and change the parent type:
import sublime import sublime_plugin class HrefCommand(sublime_plugin.ViewEventListener):
And then let's add in the methods we want overridden, we'll start simple. Can we get a hello world of sorts?
def on_query_context(self, key, operator, operand, match_all): print("Hi?") sublime.status_message(f"Hello from the plugin {key}") return None def on_query_completions(self, prefix, locations): sublime.status_message(f"Hello from the plugin {prefix}") return None
And does it work?

It does! If it doesn't work for you, make sure you've got the package files in the right place. If you accidently slip it into the User folder for example, it seems to no longer get loaded. Or at least it didn't for me before I went and started at it and realized I had missed the part that said zipped packages should be stored in Data/Installed Packages and accidently put it in there.
Understanding Sublimes ViewEventListener ↩
Every time I press save, I see that "hi"
show up, as well as in a few other places. So clearly, we need to narrow
this thing down a bit, having a noisy plugin that always wants to jump into the conversation just feels like a performance issue
begging to be had. Or well, I'd like to say that, but there is 0 documentation about what I should do there, since trying
out this code:
@classmethod def is_applicable(cls, settings): print(settings.to_dict()) return True

Makes me say "There sure is a mess of settings here!". In order to get that scope that we cared about, we'd need a reference to self
and so it seems like the is_applicable
method isn't really for turning the plugin on at runtime, but moreso for
something else entirely that we'd need to look into its usage to actually understand. So, let's ignore it and focus our
attention back to the other methods. Using what we saw in the show_scope_name
example, we can get the scope at the
place of our current caret:
def on_query_context(self, key, operator, operand, match_all): if not self.view.is_valid(): return None; scope = self.view.scope_name(self.view.sel()[-1].b).rstrip() print(scope) return None
We could do a string slice to check if we're in an html scope, like this:
if (scope[:15] != "text.html.basic"): print(scope) return None
But looking at the API reference, there's also a match_selector
method on the View class says
Whether the provided scope selector matches the Point.
And it's also mentioned in the documentation of the query completion and query context specifically for doing exactly what we're trying to do:
The list of points being completed. Since this method is called for all completions no matter the syntax, self.view.match_selector(point, relevant_scope) should be called to determine if the point is relevant.
So, let's figure out what scopes to pass to this match_selector! If I look into the HTML package itself, there's a whole bunch of useful reference code. And if I go searching for what I saw in my console already, then I can find the meta scope for href (and it seems src is included if I'm understanding line 722 right!)

The strange thing though, is that when I'm moving the arrow keys around, it feels almost like the context is lagging behind a bit. Like, moving my cursor with the arrow keys up and down between lines that land me inside of the href or not shows the wrong thing

The output in the console is from this splattering of debug prints
def on_query_context(self, key, operator, operand, match_all): print("on_query_context") if not self.view.is_valid(): return None; caret = self.view.sel()[-1].b scope = self.view.scope_name(caret).rstrip() in_html_scope = self.view.match_selector(caret, "text.html.basic") if in_html_scope is False: print(f"Not in html scope {scope}") return None print(key) # meta.string.html ? in_href = self.view.match_selector(caret, "meta.attribute-with-value.href.html") if in_href is False: print(f"Not in href") return None print(f"in scope {scope}") print("Hi!") print(key) print(operator) print(operand) print(match_all) return None
What doesn't seem to miss is the other method though, the one asking for completions. When I start typing
from within that context I see the on_query_completions [265]
show up from the placeholder
code I added before:
def on_query_completions(self, prefix, locations): print(f"on_query_completions {prefix} {locations}") return None
the space in the output is from me typing /
, I'm not really sure why that doesn't count as
a prefix to this thing. Or well, it counts as something since the method is being called, but it seems like
only alphabetical characters show up. If I press a letter then I get back on_query_completions f [265]
So it feels sort of like I'm not entirely understanding how on_query_context
is supposed to work. Because
if I ignore my feeling that I should just be using the on_query_completions
instead, and return a
true value like so:
def on_query_context(self, key, operator, operand, match_all): print("on_query_context") if not self.view.is_valid(): return None; caret = self.view.sel()[-1].b scope = self.view.scope_name(caret).rstrip() in_html_scope = self.view.match_selector(caret, "text.html.basic") if in_html_scope is False: print(f"Not in html scope {scope}") return None in_meta = self.view.match_selector(caret, "meta.string.html") if in_meta is False: print(f"Not in meta scope {scope}") return None print("Hi!") return True
Once I'm inside of the href=""
's double quotes, I'm no longer able to move up and down with the
arrow keys. Which I assume is because the editor is expecting me to be providing a set of options from the
completions method to give to the user to move through. So, with this feeling, I think that if we can't actually
provide any options, then we shouldn't be returning True at all, but false. So, for the time being I'll make
this return False
until we've written the implementation to get the options for the user.
So, using some of the other code as reference, maybe we need to figure out how to get the text written into the attribute so far so that we could use it to do a fuzzy-ish find against the files in the project. To start, I just spit out the whole line:
def on_query_completions(self, prefix, locations): print(f"on_query_completions {prefix} {locations}") point = locations[0] line = self.view.substr(self.view.line(point)) print(line) return None
And once again, I'm observing interesting behavior while trying out pressing some letters here or there:

The big thing I notice is that I get a hit on the first letter press, but then for as long as there are
other autocompletes coming through from the HTML plugin, I'm not seeing any hit from mine. Once we move
past there being any options, then I see the line logged each time. If I enter something like
"/a/v/s/f/r/a//r/"
I also see some interesting behavior:

The /
character seems to reset the prefix we're being passed in the method. And you can see
how "a" showed up at one point, while most of the time it was a space despite me typing in letters? Further
experimentation on this seems to show the letter coming through sometimes
but not always. And
so I think we're left with really only one option here: pull out what's between the "" ourselves. But...
there's probably a few cases to consider here. Like, are we always guaranteed to have a starting "
? An ending one?
What about if I'm using '
instead?
Since this is a plugin specifically for me to use (and you if it's useful, but it's designed with my own use case in mind), I think that I can probably rule our the single quote option at least. And sublime does tend to autocomplete for us and leave us with a closing quote. But it's probably reasonable to say that we need to find the closest opening " and then take the region between it and either a closing ", > or the end of the line as the potential input to any search we want to do.
That... however, feels like a lot of extra work. And I know that I said I wanted to port a feature from VSCode to sublime, but... Well. So this is how VSCode looks in the autocomplete for me when I'm trying to build a blog post and link to another file within the site:

I have to go, layer by layer, to find the file. When I find an image I have to type and navigate something like: images → enter → blag → enter → folder for post → enter → name of file I want to include → enter, and then I've got it. While, in sublime, one of the nice things is that they've got a pretty nice fuzzy find overlay:

I kind of wonder if I should make a TextCommand
that does something like this instead? This
is mainly motivated by the fact that it feels odd to have to parse and reparse the line over and over again on
each auto-completion to figure out where the text is that will guide us, versus being able to have the dedicated
input that will always have exactly what we need to search by. But... honestly? I'm writing this section after
I started the others yesterday, and feeling like I keep running into a Wall of non-understanding with sublime's
API and a similar sense of frustration as I've
found in forum posts about how lacking the documentation is for this stuff. There's the easy button of "Ask AI"
to maybe get the juices flowing, but that just makes me feel depressed and takes the wind out of my sails. The other
more important issue is that it appears that you can't actually re-use the goto overlay in the code and its not exposed
in the Default package either.
So I'm going to go take a break and find my motivation again. You might be wondering why I'm bothering to include this in the post, like, you probably don't care that I'm not feeling great today or how long it takes me to write this, just that I did write it and hopefully you can learn something from it. And to that point, I say that it's important to give your brain the space it needs to process stuff. Even if you're not working on some code, your brain is. Why do you think "shower thoughts" are a thing? Where you walk away from some hard problem, take a shower, and then realize "oh it was so simple all along!". I'm hoping to force one of these. So. I'll be back.

Alright I'm back. Had an egg sausage breakfast sandwich, a cheese mochi bun, and a Honk Kong Milk tea from a couple nearby places. Got some air. Listened to some music, played some fantasy life i. I think the best thing for us to do is to continue with out initial plan to do autocomplete, but ditch the idea of the levels that the VS code way does it, treating things more flatly. So let's get started with understanding how to actually return a result for an autocomplete, once we can do that, then I think we'll move along to the next section and be out of our prototyping phase.
The expected return type besides None
for the on_query_completions
is a list of
CompletionValues
and if we just hard code something for now we can see it in action:
def on_query_completions(self, prefix, locations): print(f"on_query_completions {prefix} {locations}") point = locations[0] line = self.view.substr(self.view.line(point)) print(line) completions = [ ["a file name", "/the/path/to/the/file"] ] completion_list = sublime.CompletionList(completions) return completion_list
Supplying a value for this list enables us to see how Sublime handles this newfound option:

As you can see, we provide the option, but the actual narrowing down is handled by sublime since it's very possible for there to be multiple plugins providing autocompletes for this particular scope and moment. so, with that in mind, I think we probably need to make sure we include something to indicate that our filename is going to expand to the full path. With that in mind, let's move along to getting the open files.
Options for our autocomplete ↩
The only files I want to suggest are the ones which are included in our currently open folders. Luckily for us, while I was browsing the Default plugin, API reference, and other materials I found window.folders which will allow us to get the paths of the currently open folders. My thought is that we can use the on_load_project_async method to run in the background and compute the open files once (since there might be a lot) and then cache them in the background. Then, our autocompletion options should be able to be pretty quick by looking at that list to find options.
However, on further inspection, it seems like that on_load_project_async
only runs when
one actually uses the Open Project dialog, it doesn't run when you add to project, open folder, or
any of those options which is annoying. So, sadly, we'll pivot again, this time to using a combination
of plugin_loaded
which is documentated (poorly) at the
lifecycle and also
doing checks during on_activated_async
because the plugin_loaded won't be ran again if I add a folder into the window at some point and while
this might be overkill, doing a quick check when a user swaps tabs is probably low impact.
So! Finally let's write some code!
# note that this is at the top level, not inside of our ViewEventListener class href_files = [] def plugin_loaded(): for window in sublime.windows(): folders += window.folders() for folder in folders: for root, dirs, filenames in os.walk(folder): for handle in filenames: relative = os.path.relpath(os.path.join(root, handle), folder) file_href_path = relative.replace(os.sep, "/") # We could make this a setting for the plugin if file_href_path[0] is not ".": href_files.append([handle, "/" + file_href_path]) print(href_files)
This first pass can then be returned by our completion list:
def on_query_completions(self, prefix, locations): return sublime.CompletionList(href_files)
And then if we type in the handle and press enter...

Useful, but not quite obvious enough. Also, we're not really doing ourselves any favors with the way the list is currently constructed when it comes to files that share the same name. Since this happens:

So, we obviously need to make this a bit smarter! We could probably return to the thing that made me flail earlier and try to use the prefix from the method call to narrow things down, but we've seen that the method isn't called every keystroke, so we'd only be narrowing so much each time Sublime asks for a refresh. So instead, let's take a different approach!

Rather than a plain tuple of text, we can construct a full
CompletionItem
that can provide a bunch of helpful information to us.
CURRENT_KIND = (sublime.KindId.COLOR_BLUISH, "➘", "Link Expansion") def completion_for(handle, file_href_path): annotation = f"/{file_href_path}" details = f"""Will expand to <strong>{annotation}</strong>""" return sublime.CompletionItem( handle, # trigger (Text to match against the user input) annotation, # hint to the right of text annotation, # completion to insert sublime.CompletionFormat.TEXT, # insert as is, no snippet CURRENT_KIND, details )
Now this works, but it doesn't cover every way my brain generally thinks. I also sometimes write part of the path out when I'm doing this. Though that might just be a habit from needing to write out the path bit by bit by VSCode. Since I'm in control now though, we can tweak our setup code to include both the path and the handle as a trigger for the options:
if file_href_path[0] != ".": href_files.append(completion_for(handle, file_href_path)) href_files.append(completion_for(file_href_path, file_href_path))
Simple and easy:

Awesome. So, that covers our options for the autocomplete then!
Handling new files ↩
Before we call this plugin complete, I want to add one last feature to it. Specifically, I want to make sure that if we make a new file or similar, that it's added to our context! Let's do a quick refactor first. Making it possible for us to add any folder to our list of completions:
known_files_tracker = {} def plugin_loaded(): for window in sublime.windows(): for folder in window.folders(): add_files_to_suggestions(folder) sublime.status_message("HrefHelper context loaded") def add_files_to_suggestions(folder): for root, dirs, filenames in os.walk(folder): for handle in filenames: full_path = os.path.join(root, handle) # Skip if we know about this already if full_path in known_files_tracker: continue relative = os.path.relpath(full_path, folder) file_href_path = relative.replace("\\", "/") # We could make this a setting for the plugin if file_href_path[0] != ".": href_files.append(completion_for(handle, file_href_path)) href_files.append(completion_for(file_href_path, file_href_path)) known_files_tracker[full_path] = 0 sublime.status_message(f"Loaded {folder} to href context")
All i've done is created add_files_to_suggestions
and added in a hashmap from the
absolute path on the operationg system to a flag value. I'm curious if this will take up a lot
of memory, and the premature optimization part of my brain wants to break it by the path separator
and push each part into a Trie, but I'll hold back for now. Since I haven't actually done the math
to see if that would actually reduce the memory we're using 4.
So, now we just have to add in a couple hooks and make sure to normalize our paths based on the root
levels of the sublime windows and we can call our handy dandy add_files_to_suggestions
method to make sure we track things!
class HrefCommand(sublime_plugin.ViewEventListener): def on_activated_async(self): active_view = sublime.active_window().active_view() if active_view is None: return file_name = active_view.file_name() inside_folder = False found_folder = None for window in sublime.windows(): for folder in window.folders(): common = os.path.commonpath([file_name, folder]) if common == folder: inside_folder = True found_folder = folder if known_files_tracker.contains(file_name): return if not inside_folder: # We have a file that is not inside any folder in sublime, # this means it's just a loose file and we have no way # to make a sensible url to it, so skip it. return # If we've hit this point, then the file is inside of a folder # that's open sublime, but we haven't indexed it. This might # mean it's a new file create in an existing folder, or there's # a brand new folder added to the sublime project we need to index # so go ahead and do that, the Trie contains check will prevent # dupes! add_files_to_suggestions(found_folder)
And now, if I drop in a folder to the sublime project and click into one of its files?

Mission success! If you watch the bottom status line of the gif, you'll see the
text from sublime.status_message(f"Loaded {folder} to href context")
appear once I click into the README file.
Wrap up ↩
There's still some other things that I'd like to do with this plugin, but I think that this is a good stopping point for the time being. The plugin's able to track the files we've got opened and provide them into the href and src meta attributes pretty well, and we handle adding new folders to the window. My general behavior when writing these blog posts doesn't involve removing any of those, so I think that I can save that for another time.
Here's a list of things that I want to add in a future blog post:
- Show the Trie implementation and how it helps with memory for the plugin
- Handle removing folders/files from the window
- Add in snippets for stuff I do like my h2's and their return to ToC arrow (↩)
- Add in a command for formatting
pre
tag contents with comments and encoding code for HTML
But all of those can wait until next time. I'm still a big fan of using Sublime Text for my editor since it checks off a lot of boxes for me. But its package/plugin documentation definitely is hard to work with. The API reference provides some clues, but there's some rough edges, like how there's no events around folders and files being added or ways to hook into the fuzzy finder to help make plugins shine even more without a lot of extra work.
It would be interesting to try to scope out the rough spots and maybe write up some tutorials for others who are still hanging out with me in the 10th most popular editor, but I'm not sure if I have use-cases that could cover trying to run into all the tough spots! Still, it's fun to customize your tools, and while I've used vim a good amount before and enjoyed pathogen, I never got bit to write anything in vimscript myself beyond some basic copypaste from StackOverflow for commmands I'd forget later anyway. Python's definitely a bit easier to use since it's more of a known quantity I think.
I had fun, I hope this has been helpful to someone out there, or at least interesting! See you all in the next post!
If you'd like to see the source code yourself or use the plugin, it's here on github.