A few more improvements to my blogging workflow

Estimated reading time 35 minutes

Last time in my continued adventurs in improving my blogging "platform", I created a bunch of snippets to help automate creating bits and pieces of HTML I commonly use. We figured out some tricks to set things dynamically, and also automated an update to the file itself to compute the reading time. All in all, a lot of good work.

Today I think we've got a few less focused ideas to tackle. But, all of these should be nice time saves for me and make it even easier to express the fun sorts of ideas I like to talk about sometimes. And, most importantly, will get me to my happy place of using just sublimetext, and not dropping into VSCode for its various handy web dev tools.

A better user experience for footnotes

Before I dive into some of the sublime text customizations, I want to cover a super simple tweak that will make your life better. And, if you're happy, I'm happy!

One of the nice things about blogging is that, assuming you provide a way for others to contact you, people do! And often, they have helpful and useful suggestions or observations. In this case, when you follow a footnote link to the bottom of the page 1, you'll notice that you can now see the original footnote reference that you clicked on.

Before I made a pretty simple CSS tweak, I hadn't noticed or realized that the sticky header for the section headings was obstructing your view of it! Which meant that if you were following the footnote, then following the arrow back up to where you had left off, you were confused and had to scroll just a little bit up to see where you were.

Obviously that's not ideal. And I was pretty happy when not only did someone point this out, but they also clued me in on the existence of scroll-margin-top. Which was the first step in figuring out how to fix this problem. The MDN docs are a decent place to start, but I also went digging in stackoverflow for a while as well, surprisingly enough, no one really mentions that handy new property. Though some do mention the use of margin-top and padding-top that I ultimately landed on. If you examine the CSS for this page you'll see this:

:target {
   margin-top: -8em;
   padding-top: 8em; 
}

I didn't end up using scroll-margin-top because it's controlling where the browser will align when you scroll with your scroll wheel. I want to avoid you having to scroll entirely! And so, a big negative margin and a padding along the top to fill it results in each anchor having dimensions like this:

Which maybe feels a bit funny, but when you jump back to the anchor, it's going to align things to fit the entire anchor into your view, and so you end up with your context back. There is one other idea I had to make your life easier that I haven't tried yet though.

It still takes a moment for me to find and refocus back onto the 1 when I return from reading a footnote. And so, taking some inspiration from a lisp video I watched recently, I decided to add this:

/* Make it easier for the person to jump to the footnote
 * reference without the sticky top being on top of it */
:target {
  margin-top: -8em;
  padding-top: 8em; 
}

/* Will help find a general target, like the footnote li pulse */
:target {
  animation: 1.0s linear forwards flash-target;
}

/* Will only work in modern browsers, but will flash the text
 * of the paragraph when we return from a footnote back to its
 * reference point.
 */
p:has(:target) {
  animation: 1.0s linear forwards flash-target;
}
@keyframes flash-target {
  0% {
    color: var(--font-color);
  }
  50% {
    color: white;
  }
  100% {
    color: var(--font-color);
  }
}

/* Prevent seizures for sensitive folks */
@media (prefers-reduced-motion: reduce) {
  * {
    animation: none !important;
    transition: none !important;
  }
}

If you've followed any footnotes, you'll have seen it in action already. But I think that this makes is easier for a reader to refocus back on the right paragraph where they left off. And if there are a lot of footnotes, then it will guide their eyes to the one that's relevant to what they were just reading.

As far as the point in the video where inspiration struck, I don't remember the specific timestamp. But at one point the instructor is talking about the balancing of the parenthesis and how lisp terminals, when you close a paren, will briefly highlight the opening paren as well. This makes it easy for a programmer to tell where the boundaries of their various compounds are. So, highlight the text I want the user to be drawn to. Simple. I just hope that a 1s pulse isn't going to be a problem for any epilitecs.

I also added in a bit of none-ing for sections and the toc since I don't think it benefits anyone to flash a wall of text or a set of list items:

section:target {
  animation: none;
}
#toc:target {
  animation: none;
}

But with that in place, I think we've improved the user experience! It was interesting to learn that you cannot animate a visited pseudo state due to "privacy restrictions". It sounds a lot like a bunch of bad actors abused link farming and JS to do some unpleasant and anti-user privacy things which is too bad. Why is it always the few who ruin it for the many? Anyway, hopefully this improves your time here at least and a few lines of css make many people have an easier time reading and jumping between footnotes and their reference points.

Now…, let's get back to improving my experience.

Live reloading web files

Keeping with the theme of this post being a little bit more tangential than focused solely on sublimetext plugin coding, we've got to talk about one of the good thing VS Code has by default.

The ability to right click and pop open a live server to view your HTML is pretty nice. But, do I really want to spend checks task manager … 783MB on a single process for this? Not really. If I have a terminal open running livereload it takes 2MB and 0.1% of my cpu.

So, really the only thing that I want to see if I can do is easily run up livereload without having to leave my editor so that I have that similar "pop this HTML open in a browser real quick" feeling that VSCode gives you. First off, installing live reload is super easy:

$ pip install livereload
Collecting livereload
  Downloading livereload-2.7.1-py3-none-any.whl (22 kB)
Collecting tornado
  Downloading tornado-6.5.1-cp39-abi3-win_amd64.whl (444 kB)
     -------------------------------------- 444.8/444.8 kB 1.1 MB/s eta 0:00:00
Installing collected packages: tornado, livereload
Successfully installed livereload-2.7.1 tornado-6.5.1

Then fire up the exe 2 with the quick and easy

livereload.exe .

and bobs your uncle. 3 We've got live reload happening for the folder we were running in. Great. That instantly makes me think about the fact that I've got multiple folders open in sublime right now. So there's our first little spec I guess:

  • We should be able to start livereload in any folder we have open

Which begs the question of if I should make it possible for me to run it in a subfolder as well. Don't you love when you think about something as simple as "and open the thing" and it turns into "but how and why and where and so on…". My plan to manage that spiral in my head though is to just get started with some thing easier.

How would we open a file in the browser? If you remember from the first time we did any of this fun stuff we looked through a lot of reference material and one of those files is the command that appears in sublime if you right click inside of an HTML file:

The code for the command is pretty tiny:

import webbrowser
import sublime_plugin

class OpenInBrowserCommand(sublime_plugin.TextCommand):
    def run(self, edit):
        if self.view.file_name():
            webbrowser.open_new_tab("file://" + self.view.file_name())

    def is_visible(self):
        return self.view.file_name() is not None and (
            self.view.file_name()[-5:] == ".html" or
            self.view.file_name()[-5:] == ".HTML" or
            self.view.file_name()[-4:] == ".htm" or
            self.view.file_name()[-4:] == ".HTM")

The observation I'm making here though is that there's a webbrowser" module available to us and it's really easy to use. So, if I make something that's aware of any running livereload instances open, then I could technically, maybe potentially offer a command to open the file with that.

The hard part of this all of course is that, I'm not sure if I should go with the braindead simple: livereload from my terminal runs on port X so always use port X and call it a day. Or if I should try to spend extra time to actually run up the process, or rather, subprocess, in sublime and capture information about that or similar.

If I were to try to re-use the exec commands to do this with just sublime commands:

{ 
   "caption": "Blag: start live-reload", 
   "command": "exec",
   "args": {
       "cmd": ["livereload.exe", "-p", "35729", "."],
   },
   "working_dir": "$folder"
}

And it claims to run, starting up in the window pane of sublime text with a

                    [I 250628 23:19:54 server:331] Serving on http://127.0.0.1:35729
                    [I 250628 23:19:54 handlers:62] Start watching changes
                    [I 250628 23:19:54 handlers:64] Start detecting changes

But, well... how do I stop it? There's always taskkill and tasklist I suppose, and we could even run those commands from sublime as well. They certainly work well enough:

But more importantly. This "folder" variable is landing on "blag" here and not at what its docs imply

$folder The full path to the first folder listed in the side bar.

Certainly sounds like it should be "peetseater.space" if I have that whole folder open in my editor, doesn't it? I thought maybe it should be this one instead then:

$project_path The path to the folder containing the current project file.

But no dice. This is unfortunate, since opening the livereload server from the folder that the current file is in means that none of our images load. Obviously, I could run the command while I had the top level index file open, but that's not really any good at all. So, instead, we'll have to abandon the idea of using the .sublime-commands file and instead drop back down into python.

import sublime
import sublime_plugin
import os
import subprocess

class LiveReloadStartCommand(sublime_plugin.WindowCommand):
    def run(self):
        if not self.window.active_view():
           print("no active view")
           return

        sheet_file_name = self.window.active_sheet().file_name()
        if sheet_file_name is None:
           print("file not saved yet")
           return

        longest_path = None
        for folder in self.window.folders():
           in_common = os.path.commonprefix([sheet_file_name, folder])
           if longest_path is None or len(in_common) > len(longest_path):
               longest_path = in_common

        print(f"cwd will be {longest_path}")
        return; # temporary stop before we run the command

Wiring this up in our sublime-commands file by adding

{ "caption": "Blag: start live-reload", "command": "live_reload_start" }

to the list lets me test that I haven't gotten my thoughts wrong about this so far:

reloading plugin HrefHelper.previewserver
cwd will be C:\full\path\to\Code\Personal\peetseater.space

Worked first try! Man, I really love how simple the os.path.commonprefix makes this type of stuff. Alright, since I can get what we want the current working directory to be, we can use that when we run up the commands. But also, let's be smart! Since I know I'll have multiple folders open, let's just make sure that we run multiple livereloads if we need to:

...

folder_to_running_processes = {}
port_to_start_at = 35729

class LiveReloadStartCommand(sublime_plugin.WindowCommand):
  def run(self):
      ... code from before finding the root folder our file is in...

      if longest_path in folder_to_running_processes:
          print("livereload is running and will be restarted?")
          folder_to_running_processes[longest_path].kill()

      global port_to_start_at
      command = ["livereload.exe", "-p", f"{port_to_start_at}", longest_path]
      try:
          process = subprocess.Popen(
              command,
              cwd=os.path.dirname(longest_path),
              stdout=subprocess.PIPE,
              stderr=subprocess.PIPE,
              shell=True
          )
          folder_to_running_processes[longest_path] = process
          port_to_start_at += 1
          print(command) # I am debugging :) 

          sublime.status_message(f"livereload.exe on port {port_to_start_at - 1}")
      except Exception as e:
          sublime.error_message(f"Failed to run command: {e}")

Though, this definitely isn't perfect. Let's not have the port increment every single time, since if I run the command 3 times I end up with

which isn't quite what I want. I want the port to stay consistent for the given folder so that I don't have to mess with my browser at all. So, if we track the process and its port in a tuple, then we can do this:

global port_to_start_at
process_port = port_to_start_at

if longest_path in folder_to_running_processes:
  print("livereload is running and will be restarted?")
  [process, previously_used_port] = folder_to_running_processes[longest_path]
  process_port = previously_used_port
  process.kill()

command = ["livereload.exe", "-p", f"{process_port}", longest_path]
try:
  process = subprocess.Popen(
      command,
      cwd=os.path.dirname(longest_path),
      stdout=subprocess.PIPE,
      stderr=subprocess.PIPE,
      shell=True
  )
  sublime.status_message(f"Started livereload.exe on port {process_port}")
  folder_to_running_processes[longest_path] = (process, process_port)
  if port_to_start_at == process_port:
      port_to_start_at += 1
  print(command)
  
except Exception as e:
  sublime.error_message(f"Failed to run command: {e}")

Spamming the command a bit and swapping folders shows me that the expected thing is happening. New folder? New port! Old folder? Same port!

['livereload.exe', '-p', '35729', 'path\\to\\Personal\\peetseater.space']
['livereload.exe', '-p', '35730', 'path\\to\\Packages\\HrefHelper']
livereload is running and will be restarted?
['livereload.exe', '-p', '35730', 'path\\to\\Packages\\HrefHelper']
livereload is running and will be restarted?
['livereload.exe', '-p', '35729', 'path\\to\\Personal\\peetseater.space']

So that's good! Now, if I reload my plugin however, I lose my variables and that's no good. So, let's make it easy to kill off any and all livereloads that are going. As before, we can update our .sublime-commands file with a reference to the command we're about to make:

{ "caption": "Blag: stop all live-reload", "command": "stop_all_live_reload_servers" }

And then make a window command that matches the command's name:

class StopAllLiveReloadServers(sublime_plugin.WindowCommand):
   def run(self):
       print("Shuting down all livereload servers")
       command = ["taskkill.exe", "/IM", "livereload.exe", "/F"]
       try:
           process = subprocess.Popen(
               command,
               stdout=subprocess.PIPE,
               stderr=subprocess.PIPE,
               shell=True
           )
           sublime.status_message(f"Stopped all livereload servers")
           global port_to_start_at
           port_to_start_at = 35729
           global folder_to_running_processes
           folder_to_running_processes = {}
       except Exception as e:
           sublime.error_message(f"Failed to run command: {e}")

Now obviously this is only going to work on windows. So, if we were trying to make this work on any and all machines, I'd need to tweak things a bit, but for the time being I'll just leave it as is. 4 Mainly because we haven't even gotten to the replacement command yet! We've got the server running, and we've got a reference to it in sublime text, and so now we can add in a new menu command!

I'll add in a context menu similar to VSCode's for the sake of old habits, but I'd bet you at least 1 dollar that I'll probably trigger it mostly from the command palette since typing is faster than moving my hand to the mouse and back. In Context.sublime-menu in my package I can add this in:

[
   { "caption": "-", "id": "file" },
   { "caption": "-", "id": "blag" },
   { "command": "open_in_live_reload", "caption": "Live reload this folder" },
   { "caption": "-", "id": "end" }
]

And this will produce the following menu for me:

The id and the order thereof in the context file helps sublime figure out where to put in my new slot. In this case, I've specified both file and end slots so that the blag area can be at the bottom. I thought about tring to slip it into the same area as the "Open in Browser" command, or maybe even replace that, but for ease of testing, I'm just going to use a new section.

This doesn't even do anything yet, so let's fix that! This is going to be a mix of what we saw with the Open in browser command and the live reload commands so far, hold your nose, we're using globals again!

class OpenInLiveReloadCommand(sublime_plugin.TextCommand):
   def run(self, edit):
       if not self.view.file_name():
           return

       sheet_file_name = self.view.window().active_sheet().file_name()
       if sheet_file_name is None:
           print("file not saved yet")
           return

       longest_path = None
       for folder in self.view.window().folders():
           in_common = os.path.commonprefix([sheet_file_name, folder])
           if longest_path is None or len(in_common) > len(longest_path):
               longest_path = in_common

       global folder_to_running_processes

       if longest_path not in folder_to_running_processes:
           print("No running live reload yet!")
           sublime.run_command("live_reload_start") # <-- this doesn't work!

       if longest_path not in folder_to_running_processes:
           print("Could not get a livereload port determined, bailing out")
           return

       [process, process_port] = folder_to_running_processes[longest_path]

       relative_file_path = os.path.relpath(self.view.file_name(), longest_path)
       web_path = "/".join(relative_file_path.split(os.sep))
       url = f"http://127.0.0.1:{process_port}/{web_path}"
       webbrowser.open_new_tab(url)

   def is_visible(self):
       return self.view.file_name() is not None and (
           self.view.file_name()[-5:] == ".html" or
           self.view.file_name()[-5:] == ".HTML" or
           self.view.file_name()[-4:] == ".htm" or
           self.view.file_name()[-4:] == ".HTM")

putting aside the ugliness of the print statements, the repeated code, and the callout that I've got something in here that doesn't work, this works! 5 I can right click (or use the command palette) and my current html file I'm in will open up in the default browser of my OS.

There's not a ton to say about the code except for os.path.relpath, much like os.path.commonprefix is handling the heavy thinking here for us on relativizing the path for us. In Java I believe I'd be using Path.resolve() between then and forgeting the order of whether the parent or child should be doing the call. But, in Python, the docs are pretty good and the signature of os.path.relpath(path, start=os.curdir) is clear.

Let's make sure that when we run this command, context menu or palette driven, that if a live reload server is not currently running it boots one up for us. This will give us the same functionality as VSCode's built in helper. To do this, first thing's first, let's refactor!

The code that I wanted to run via sublime.run_command is in LiveReloadStartCommand, basically the bit that comes after all the guard checks. Let's lift that up into a plugin level function:

def start_live_reload(folder_path):
   global port_to_start_at
   global folder_to_running_processes
   process_port = port_to_start_at

   if folder_path in folder_to_running_processes:
       print("livereload is running and will be restarted?")
       [process, previously_used_port] = folder_to_running_processes[folder_path]
       process_port = previously_used_port
       process.kill()

   command = ["livereload.exe", "-p", f"{process_port}", folder_path]
   try:
       process = subprocess.Popen(
           command,
           cwd=os.path.dirname(folder_path),
           stdout=subprocess.PIPE,
           stderr=subprocess.PIPE,
           shell=True
       )
       sublime.status_message(f"Started livereload.exe on port {process_port}")
       folder_to_running_processes[folder_path] = (process, process_port)
       if port_to_start_at == process_port:
           port_to_start_at += 1
       print(command)
   except Exception as e:
       sublime.error_message(f"Failed to run command: {e}")

With this in a helper method, the two commands can just re-use that and things will be both slightly DRY-er and also I won't have to wonder why run_command did not, in fact, run the command.

class LiveReloadStartCommand(sublime_plugin.WindowCommand):
   def run(self):
       ...
       longest_path = None
       for folder in self.window.folders():
           in_common = os.path.commonprefix([sheet_file_name, folder])
           if longest_path is None or len(in_common) > len(longest_path):
               longest_path = in_common
       start_live_reload(longest_path)

class OpenInLiveReloadCommand(sublime_plugin.TextCommand):
   def run(self, edit):
       ...
       if longest_path not in folder_to_running_processes:
           print("No running live reload yet!")
           start_live_reload(longest_path)

And with that, I've got the functional equivalent to something that requires over half a gig in the bloatware VSCode users call an editor. Except of course, mine's better:

That said, before we move onto the next little improvement. I want to get rid of all these print statements. They're super useful for debugging while I'm actively thinking about the plugin, but in my usual, if something goes wrong, I'm not going to want to pop open the debug shell for that.

I've been using sublime.status_message here and there. But that is quickly overridden and isn't suited for letting my know that something went wrong in a visible way. So instead, let's consider using either sublime.error_message or sublime.message_dialog for our purposes.

I don't think I need to spend the screen space here to repaste each of the code snippets so far to tell you that all I did was search for each print and then replace them with an appropriate call. If it was an error, error_message. If it felt like something I'd want to stop my world for and call attention to via a pop up, message_dialog. If it was something that was happening thats noteworthy but transient, status_message.

Speaking of code snippets though…

Escaping code

In a way, this sort of thing is actually why I stuck with VSCode for as long as I did. In addition to the autocomplete I replaced in the first post of this series, it also has good support for vim motions and some basic vim-like commands. And so, for doing something like taking a <pre> tag's contents and adding in <!----> to cover the indentation whitespace and then make sure that the common HTML entities for < and friends are escaped properly, I'd take some code snippet between pre tags, quickly swap to visual mode and expand the selection out to the tag boundaries with shift+V vat and then scroll up to my usual replacements like so:

If there's a < in the code snippet, or an & or similar, I'd run a replace for that before fixing up the whitespace indentation. This doesn't take much time, but it was satisfying that little itch in the back of your head that says: learn vim commands and get the dopamine from executing commands in visual mode!

In sublime, there's something that almost works for this built in from the HTML plugin:

import sublime_plugin
from html.entities import codepoint2name as cp2n

class EncodeHtmlEntities(sublime_plugin.TextCommand):
  def run(self, edit, **args):
    view = self.view

    for sel in view.sel():
      buf = []

      for pt in range(sel.begin(), sel.end()):
        ch = view.substr(pt)
        ch_ord = ord(ch)

        if not view.match_selector(pt, 'meta.tag - string, constant.character.entity') and \
            ch_ord in cp2n and \
            not (ch in ('"', "'") and
               view.match_selector(pt, 'string')):
          ch = '&%s;' % cp2n[ch_ord]

        buf.append(ch)

      view.replace(edit, sel, ''.join(buf))

I say almost works because if you're trying to run it on an HTML snippet then it behaves in a pretty interesting way:

If you're saying to yourself: "okay, I assume he ran the command... why did nothing change?" Congrats, you're asking the same thing I did when I first discovered it!

My assumption here is that the intending context for when you'd be running the encode_html_entities command is when you've got a full HTML file open and want to tell the whole thing (or selection) to take any plain text strings intermixed with your HTML and escape only the text and leave any tags you added for markup alone. That makes a lot of sense! But isn't what I need.

So let's see if we can figure out how to do this. My first question is if there's an easy way to tell that the current scope is a pre tag or not. Running ctrl+shift+alt+P gives the answer:

Not really. If you watch the gif above again, you can see the selection creeping out 7 because I'm pressing ctrl shift A each time. That knows how to expand... so maybe we can learn something from that if we can pry open the source code and understand it. Looking in the Default.sublime-keymap file:

{ "keys": ["ctrl+shift+a"], "command": "expand_selection", "args": {"to": "smart"} },
{ "keys": ["ctrl+shift+m"], "command": "expand_selection", "args": {"to": "brackets"} },
{ "keys": ["ctrl+m"], "command": "move_to", "args": {"to": "brackets"} },
{ "keys": ["ctrl+shift+a"], "command": "expand_selection", "args": {"to": "tag"}, "context":
    [
       { 
           "key": "selector", 
           "operator": "equal", 
           "operand": "(text.html, text.xml) - source", "match_all": true
       }
    ]
},

We can see that there's both a regular "expand_selection" command, and one which takes precedence when the context is within html files. It's neat to see to: tag here as part of arguments. Since this fills me with a bit of hope that I might be able to potentially specify a specific command here.

however, upon looking for a class named ExpandSelectionCommand or similar, I found nothing in the Default plugin within sublime text. That said, I did find a reference to view.expand_to_scope within the ToggleCommentCommand class:

class ToggleCommentCommand(sublime_plugin.TextCommand):
    def remove_block_comment(self, view, edit, region):
        scope = view.scope_name(region.begin())

        if region.end() > region.begin() + 1:
            end_scope = view.scope_name(region.end() - 1)
            # Find the common scope prefix. This results in correct behavior in
            # embedded-language situations.
            scope = os.path.commonprefix([scope, end_scope])

        index = scope.rfind(' comment.block.')
        if index == -1:
            return False

        selector = scope[:index + len(' comment.block')]

        whole_region = view.expand_to_scope(region.begin(), selector)
        ...

Which clued me into the fact that we've got a number of expansion related methods on the view itself we could maybe use. But if we want to do that, then we need to determine if the HTML plugin defined a scope that allows us to target the pre tag itself, or if we're stuck with something more brutish. Within the HTML.sublime-syntax file I can see:

variables:
  html_text_content_tags: |-
    (?xi: blockquote | cite | dd | dt | dl | div | figcaption | figure | hr
    | li | ol | p | pre | ul ){{tag_name_break}}

And that's the only place that pre is mentioned. Looking in the un-official documentation from the community site:

At their core, syntax definitions are arrays of regular expressions paired with scope names. Sublime Text will try to match these patterns against a buffer's text and attach the corresponding scope name to all occurrences. These pairs of regular expressions and scope names are known as rules.

Syntax definitions from separate files can be combined, and they can be recursively applied too.
source

Oh boy. So basically, syntax is a bunch of regex. This sounds nightmarish to me since there's nothing that really melts my brain faster than trying to read a lot of regular expressions and hoping that my mental model somehow constructs the correct finite state machine the computer will execute. With this in mind, the html_text_content_tags variable I saw before is actually just a declaration of a regex, and that's included within another variable named html_block_tags which is then used in the contexts section of the syntax file like so:

contexts:
  tag:
    - include: tag-html
  tag-html:
    - include: script-tag
    - include: style-tag
    - match: (</?)({{html_structure_tags}})
      captures:
        1: punctuation.definition.tag.begin.html
        2: entity.name.tag.structure.any.html
      push: structure-tag-content
    - match: (</?)({{html_block_tags}})
      captures:
        1: punctuation.definition.tag.begin.html
        2: entity.name.tag.block.any.html
      push: block-any-tag-content

All of which is to say: a pre tag is a block html tag and that's all I can really get from the scope defined by the syntax. Dang.

Wait a minute though. What was that last bit that I read before?

Syntax definitions from separate files can be combined, and they can be recursively applied too.

Could I define my own little extension to get the pre tags to be their own scope?

When extending a syntax, the variables key is merged with the parent syntax. Variables with the same name will override previous values.
Variable substitution is performed after all variable values have been realized. Thus, an extending syntax may change a variable from a parent syntax, and all usage of the variable in the parent contexts will use the overridden value.
source

So... well, let's see if we can do this?

After an hour of fiddling, copying, crying, and trying to make sense of the documentation around this stuff. I got a syntax file defined and it appeared in the listing for selection:

But, if I tried to swap to it?

Hm. Remember how I just said that regex generally makes my brain melt? Well, turns out that beating my head against the wall to try to understand how to understand inheritance within a yaml file and how to properly define variables in a way that makes any degree of sense did, as expected, melt my brain. In the interest of not wasting my entire afternoon on this, I'm going to consider this approach a bit beyond me at the moment as stuff like this:

contexts:
  main:
    - include: html
  tag:
    - include: tag-html
    - include: tag-other
    - include: tag-incomplete
  tag-html:
    - include: pre-tag
    - match: (</?)(?xi:pre)({{tag_name_break}})
      captures:
        1: punctuation.definition.tag.begin.html
        2: entity.name.tag.structure.pre.html
      push: pretag
  pre-tag:
    - match: (<)((?i:pre)){{tag_name_break}}
      captures:
        1: punctuation.definition.tag.begin.html
        2: entity.name.tag.pre.html
      push: pre
    - match: (</)((?i:pre){{tag_name_break}})
      captures:
        1: punctuation.definition.tag.begin.html
        2: entity.name.tag.pre.html
      push: pre-close-tag-content  

While, seemingly sensible with the ideas, just doesn't work for me yet. And I'm not clever enough or well versed in these particular internals to understand exactly why. With no ground to speak on, I think I'll shift back to where I'm more comfortable. Let's do this in code instead.

I know that punctuation.definition.tag.begin.html is where the pre tag is going to start. We just don't know if we're at the correct tag or not. It seems like I should be able to recursively expand from whatever point the cursor is at until I find one though. Let's write a little debug command for this. First, a quick hello world:

import sublime
import sublime_plugin

class EscapeCodeSnippetCommand(sublime_plugin.TextCommand):
    def run(self, edit):
        html = "<strong>hi</strong>"
        self.view.show_popup(html, max_width=512, max_height=512)

And then let's try out expanding.

selector = 'punctuation.definition.tag.begin.html'
whole_region = self.view.expand_to_scope(sel[0].begin(), selector)
html = f"<strong>hi {pre_tag_locations}</strong>"
self.view.show_popup(html, max_width=512, max_height=512)

And... this only ever shows something in the popup if I'm directly on the start of a pre tag, it doesn't give me anything beyond None elsewhere, which is odd. But, well, if this expand method on view isn't the same sort of thing that those keymapped commands we saw is, then I guess we'll just have to do this the old fashioned way! Screw your fancy expansion, we're loopin!

import sublime
import sublime_plugin

class EscapeCodeSnippetCommand(sublime_plugin.TextCommand):
    def run(self, edit):
        current_caret = self.view.sel()
        pre_tag_locations = []
        previous_region = None
        regions_backwards = reversed(self.view.find_by_selector("text.html.basic meta.tag.block.any.html entity.name.tag.block.any.html"))

        for region in regions_backwards:
            # Keep track of the region in front of our current one
            if previous_region is None:
                previous_region = region
                continue

            tag = self.view.substr(region)
            if not tag == "pre":
                continue

            if current_caret[0].begin() > region.end() and current_caret[0].begin() < previous_region.begin():
                pre_tag_contents_region = sublime.Region(region.end(), previous_region.begin())
                pre_tag_locations.append(pre_tag_contents_region)
                self.view.sel().add(pre_tag_contents_region)

            previous_region = region

            if current_caret[0].begin() > region.end():
                break

        html = f"<strong>hi {pre_tag_locations}</strong>"
        self.view.show_popup(html, max_width=512, max_height=512)

The self.view.sel().add(pre_tag_contents_region) is a sort of debug thing for myself to confirm that yes, we're really doing it right. And, if I run this from my editor inside of a random pre tag?

The little red arrow is where my cursor started before I ran the command. And so you can easily tell that it expanded correctly. Awesome. My edges are a bit messy here, since we're not seeing the / in the closing tag, and I suppose in general we'll need to figure out what level of indentation we're at, but I think we're pretty close to what we need. So let's add in some more code.

# We now have the regions where pre>[0] ends and [1]</pre starts. 
# Escape the content inbetween for HTML:
modified_html = ""
for region_to_escape in pre_tag_locations:
    unescaped = self.view.substr(region_to_escape)
    escaped = html.escape(unescaped)

    # then we need to handle whitespace indentation with HTML comments
    ending_line = self.view.substr(self.view.line(sublime.Region(region_to_escape.end(), region_to_escape.end())))

    # Tabs need to be converted to spaces so we can build the comment's dashes properly
    tab_size = get_tab_size(self.view)
    line_with_only_spaces = ending_line.expandtabs(tab_size)

    character_space_for_comment = line_with_only_spaces.find("</pre>")
    comment_dashes = "-" * (character_space_for_comment - 3)
    comment_prefix = f"<!{comment_dashes}>"

    for line in escaped.split("\n"):
        modified_html += f"{comment_prefix}{line[len(comment_prefix):]}\n"

print(modified_html)
self.view.show_popup(html.escape(modified_html), max_width=512, max_height=512)

This is close but not quite right yet:

It's a bit easier to see the print here, but our indentation is a bit off here since it's only indenting as far as the pre tag. When I escape the code for the blog, I escape all the way to the > so that no extra whitespace exists in order to keep the horizontal space as small as I can. The indentation is correct for the level of the tag, but not for the body of said tag.

That said, that should be pretty easy to do! We know how long the tag is after all, and we can just expand it out, but one other thing I'd like to be careful of is if I haven't indented the code in the pre tag at all, we should pad things out! This will really make it possible to just run the command and call it a day even if I just dumped a bunch of code in and it's all over the place indentation wise.

That said, all those little edges make for a bit of gnarly code and I'm sure there's a better way to do it, but here's my first pass that's almost almost there:

character_space_for_comment = line_with_only_spaces.find("</pre>")
comment_dashes = "-" * (character_space_for_comment - 3)
comment_prefix = f"<!{comment_dashes}---->" # align > with ending > of <pre>
comment_prefix_endingline = f"<!{comment_dashes}>"

region_lines = self.view.lines(region_to_escape)
last_index = len(region_lines) - 1
for index, line_region in enumerate(region_lines):
    if index == last_index:
        comment_prefix = comment_prefix_endingline

    padding_length = len(comment_prefix)

    this_line = self.view.substr(self.view.line(line_region))
    this_line_notabs = this_line.expandtabs(tab_size)

    # Ensure that even if there's not enough space, we get some
    space_at_start = 0
    for c in this_line_notabs:
        if c.isspace():
            space_at_start += 1
        else:
            break

    if space_at_start < padding_length:
        padding = " " * (padding_length - space_at_start)
        this_line_notabs = f"{padding}{this_line_notabs}"

    escaped = html.escape(this_line_notabs[len(comment_prefix):])
    new_line = f"{comment_prefix}{escaped}\n"
    modified_html += new_line                    
                

Like I said, this is almost there, the problem is, as you can see, my usage of the self.view.line method:

Unlike the previous code snippet, which only used the boundaries of the region expanded up until the pre tags, this one uses the lines helper to fetch each line overlapping with said region. That's great for most of the lines, but not the last one. So, we'll just be a bit cheeky here instead of getting complicated:

if index == last_index:
    modified_html += comment_prefix
else:
    modified_html += new_line

And then?

Escaping? Check. Didn't eat away at the content? Check! It's always nice when something works after a hard sunday afternoon's work.

RSS Snippets

While the subject of my HTML snippets was covered quite heavily in the previous entry of this series. I don't just manage HTML for this blog. There is also a handy dandy RSS feed for the blog posts. This XML file is one which I edit, and more-often-than-not screw up in some way.

Thankfully we have validator websites, but I think it'll be better if I just use a snippet to help me set my dang dates right and avoid nonsense from happening without needing an internet connection. The main thing that trips me up, is the timestamp format and making sure that I write it in the current timezone. A typical RSS feed item looks like this:

<item>
  <title>Dynamic snippets in sublime text</title>
  <link>https://peetseater.space/blag/2025-06-21-sublimetext-blogging-customizations.html</link>
  <guid>https://peetseater.space/blag/2025-06-21-sublimetext-blogging-customizations.html</guid>
  <pubDate>Sun, 22 Jun 2025 02:47:00 GMT</pubDate>
  <description>
    ![CDATA[
      Continuing my adventures in sublimetext plugins, I figured out how to dynamically
      insert content based on the current file's context into a snippet! I also made
      a few other neat features for me to use while I blog! 
    ]]
  </description>
</item>

As we saw last time, if you want to write everything yourself, you can just use sublimetext's standard snippet methods. But if you want dynamically generated values in the output? You've gotta create the python snippet yourself. So, we'll be extending our view listener we made before. The little pattern I established last time was creating the snippet in the class init.

class DynamicSnippetsViewEventListener(sublime_plugin.ViewEventListener):
    def __init__(self, view):
        self.view = view
        self.footnote_snippet = inspect.cleandoc("""
            <li id="footnote-{0}">
                $0
                <a href="#footnote-{0}-ref">&#8617;</a>
            </li>
            """)

And then creating helpers to construct the snippet itself for sublime

def footnote_completion(self):
    new_snippet = self.footnote_snippet.format(f"{self.number_of_footnotes_in_file + 1}")
    return sublime.CompletionItem(
        "blag_footnote",
        annotation = "A footnote for the bottom of the page",
        completion = new_snippet,
        completion_format = sublime.COMPLETION_FORMAT_SNIPPET,
        kind=sublime.KIND_SNIPPET
    )

And then returning them as needed:

def on_query_completions(self, prefix, locations):
    for point in locations:
        in_html_scope = self.view.match_selector(point, "text.html.basic")
        if in_html_scope is False:
            return None

    if "blag" not in prefix:
        return None

    self.calculate_footnote_count()
    self.calculate_section_count()

    prefilled_snippets = [
        self.footnote_completion(),
        self.footnote_ref_completion(),
        self.new_section_completion()
    ]
    return sublime.CompletionList(prefilled_snippets)

Looking at this last snippet, we can spot a bit of a snag. Our dynamic snippets helper is only currently caring about HTML scopes. Unsurprisingly, an XML file has a different scope:

So, I can either created a separate class for the XML related snippets, or I could tweak our on_query_completions method to pull out the appropriate completions based on the results of the match selector. Decisions, decisions. I think that future me will appreciate a clear dilineation, and so let's rename DynamicSnippetsViewEventListener to DynamicHTMLSnippets and make an XML one.

class DynamicXmlSnippets(sublime_plugin.ViewEventListener):
    def __init__(self, view):
        self.view = view
        self.feeditem_snipet = inspect.cleandoc("""
            <item>
                <title>$1</title>
                <link>$2</link>
                <guid>$2</guid>
                <pubDate>{0}</pubDate>
                <description>
                    ![CDATA[
                        $0
                    ]]
                </description>
            </item>
            """)

Just like the HTML one, I'm going to declare the snippet once in the class. One Noteworthy thing here are that my link and guid are just the canonical url for the website, so they both get $2 so it will be fill in both as I type. The only other bit is that we've got the sublime text replacements reflected with the dollar sign prefixed numbers, and then the dynamic part ready to be formatted by python with {0}.

from email.utils import formatdate
...
def new_feed_item_completion(self):
    # Current time in RFC 822 format for RSS
    todays_date_in_gmt = formatdate(timeval=None, localtime=False, usegmt=True)
    new_snippet = self.feeditem_snipet.format(f"{todays_date_in_gmt}")
    return sublime.CompletionItem(
        "blag_xml_item",
        annotation = "a new rss item",
        completion = new_snippet,
        completion_format = sublime.COMPLETION_FORMAT_SNIPPET,
        kind=sublime.KIND_SNIPPET
    )       

a helper method to perform the actual dynamic inclusion of the date and return the sublime text helper isn't anything new. The only fun bit is that I didn't know about formatedate until I started searching around for RFC 822 python code and stumbled across it. So that's always nice to put in the back pocket.

Lastly, the query completions method sublime will use to fetch back out anything we have to offer:

def on_query_completions(self, prefix, locations):
    for point in locations:
        in_xml_scope = self.view.match_selector(point, "text.xml")
        if in_xml_scope is False:
            return None

    if "blag" not in prefix:
        return None

    prefilled_snippets = [
        self.new_feed_item_completion()
    ]
    return sublime.CompletionList(prefilled_snippets)

It's really quite similar to the other, if I was doing this for a few more file types I'd probably consider creating a generic template method type class to extend from. But since we're not nearly at that point, I think copying and pasting is just fine for the time being.

And the moment of truth, does it work?

Like a charm! My snippets that I'm using for the web paths aren't active in the XML context though, which means it's a bit harder than neccesarily to get the full path to the blog post I just wrote. Looking back at that completion helper:

class IndexedFilesCompletionsViewEventListener(sublime_plugin.ViewEventListener):
    def on_query_completions(self, prefix, locations):
        # If someone is using multicursors, ensure all cursors
        # are within the correct context before we offer ourselves
        # as an option
        for point in locations:
            in_html_scope = self.view.match_selector(point, "text.html.basic")
            if in_html_scope is False:
                return None

            in_meta = self.view.match_selector(point, "meta.string.html")
            if in_meta is False:
                return None

            in_href = self.view.match_selector(point, "meta.attribute-with-value.href.html")
            if in_href is False:
                return None

        return sublime.CompletionList(href_files)

It wouldn't be too hard to do one little tweak... adding in something like

in_xml_scope = self.view.match_selector(point, "text.xml")
if in_xml_scope:
    return sublime.CompletionList(href_files)

but the only problem with that is that now those autocompletes take up a ton of space in any context of the XML:

I could maybe check the prefix as being the website name. But typing out https://peetseater.space then pressing tab will result in only space being passed to the on_query_completions method in the prefix variable. Which, sorta could work, except for the fact that it'll do this when you complete it:

And that's not putting aside the fact that you have to backspace to be able to search for the right file, it screws up the domain name, and the very very important bit: you can only tab compete if you're not using tab to jump between parts of the snippet! Pressing tab when you're in snippet-creation mode doesn't wake up the autocomplete, it jumps you to the next $number placeholder. so, for the time being, I think we'll give up on using our indexed files for this XML autocomplete for now.

Wrapping up

Well, this felt like a slightly shorter post. Though looking at the scrollbar, that maybe isn't actually the case. It felt like we tackled a few BIG things rather than a bunch of small ones like last time.

I didn't use VSCode to write this post at all, well, besides booting it up to grab screenshots and such for comparison, and was pretty happy with the experience. From the moment I got the code snippet helper working, it became way way faster to include each of those in. I think there's still a few things I could probably do, such as add in some autocompletes for common HTML entities or maybe look into using livereload's API directly in order to avoid having to hack around sublime's lack of file watch.

But, honestly? I think we'll take a break from some of these editor tweaks! Why? Because I had some really good ideas on a plane ride this past week for some projects! Specifically, and hey, no stealing my ideas, but:

  • A variant on the Trie puzzle game I made before
  • Investigating RDF and how one might use it to make a site search
  • Raylib keyboard overlay to capture keystrokes for better blog gifs and such
  • Adding sqlite to my file deduper rust util
  • Whatever things happen when I get my hands on a new computer and start using PopOS

No commitments from me on which one of these you might see show up here. But we'll see where the future months take us! For now, I'm going to finish up the last bit of listening I had on the nice music I was listening to whilst programming all this stuff!