Trending
brettterpstra.com.web.brid.gy's Avatar

brettterpstra.com.web.brid.gy

@brettterpstra.com.web.brid.gy

18
Followers
0
Following
497
Posts
01.01.0001
Joined
Posts Following

Latest posts by brettterpstra.com.web.brid.gy @brettterpstra.com.web.brid.gy

Preview
bt-linkding available on the Chrome and Firefox extension stores This is just a quick note to point out that my port of the linkding browser extension for Chrome and Firefox is now available on both of the respective extension stores. * **Firefox** via the Firefox extensions marketplace * **Chrome** via Chrome Web Store Hope you find it useful! Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
13.03.2026 13:31 👍 0 🔁 0 💬 0 📌 0
Preview
Web Excursions for March 11th, 2026 Web excursions brought to you in partnership with Fabric, the best way to organize your notes, tasks, and projects in one place. Tokie Ok, this is cool. A macOS file manager that turns your folder into a database for better file management, with some very cool integration with your AI agent, built-in Markdown editor, custom fields for file management, and a ton of other capabilities. A proposal for Markdown on ATProto > Providing a Lexicon for putting Markdown in the ATmosphere. Fits nicely into my thoughts about a Markdown Web and has a fair amount of thought and feedback already in the spec. GitHub Actions for WordPress I know this has a limited audience, but if you develop WordPress plugins and haven’t explored 10up’s GitHub actions, you really should. The deploy one is infinitely useful and means you never have to deal with SVN after intial repo setup. rhsev/matterbase Ralf keeps putting out cool stuff: “A database-like TUI for querying frontmatter and YAML in Markdown notes with field filters, full-text search, and table view. For macOS and Linux.” Let Fabric be your second brain, with an all-in-one AI workspace and smart organizer for all your projects, ideas, notes & links. Check it out today. Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
11.03.2026 17:00 👍 0 🔁 0 💬 0 📌 0
Preview
Unite Pro giveaway! I’m excited to offer the next giveaway, 4 licenses ($39.99 value each) for Unite Pro. Unite has long been my favorite way to create Single Site Browsers (SSBs), sandboxing things like Facebook and MindMeister while adding app-like functionality. The latest version, Unite Pro, is out now, and I have free copies! From the developer: > We’ve taken everything we’ve learned since 2017 and rethought it for modern macOS. The result is faster, more flexible, and significantly more powerful — while staying true to what makes Unite valuable: turning web apps into genuine Mac-native experiences. Check out the Unite Pro site for more info. Sign up below to enter. Winners will be randomly drawn on Friday, March 13, at 12pm Central. The drawing is for 4 licenses ($39.99 value each) for Unite Pro, one per winner. Note that if you’re reading this via RSS, you’ll need to visit this post on brettterpstra.com to enter! New rule: All signups must have a **first and last name** in order to be eligible. Entries with only a first name will be skipped by the giveaway robot. A lot of the vendors in this series require first and last names for generating license codes, and your cooperation is appreciated! You need to view this post on brettterpstra.com to enter. If you have an app you’d love to see featured in this series of giveaways, let me know. Also be sure to sign up for the mailing list or follow me on Mastodon so you can be (among) the first to know about these! Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
09.03.2026 13:00 👍 0 🔁 0 💬 0 📌 0
Preview
Put all the web browsers in your Dock Here’s a silly idea I had. I don’t like to keep a bunch of icons in my Dock, preferring to use Bunch along with a simple set of persistent Dock apps. I do keep Firefox in the Dock, but I use a bunch of browsers for different purposes, so what if I could access all of them from the Dock without polluting it? I use Choosy as my default browser. It allows me to pop up a menu or select a default browser whenever I open a link, based on custom rules. There are multiple variations of this idea, but Choosy is the original and still my favorite. Choosy has a url scheme that you can use to pop up browser menus, among other things. So getting a menu of all my browsers is as easy as setting up a Shortcut to open `x-choosy://prompt.all/URL`. For this purpose, I’m using the Kagi search page as my URL, since that’s where most of my browsing sessions start. > You can also just open `x-choosy://prompt.all/` to open a browser without opening a web page. Then you can just right click on the Shortcut in the Shortcuts app and “Add to Dock,” position it where you want it, and you’re done. One click to open any of your browsers. I tried adding a custom icon to it, but failed, so I just have a “Shortcuts” icon in my Dock. When I click the shortcut in the Dock, I get the Choosy browser menu, with my running browsers highlighted but all of my browsers accessible. There’s probably a way to get it to work well as a Share item for URLs, but the fact is that if I’m opening a URL, I already have Choosy as a share option, and if I’m opening from a browser, I have a Choosy plugin to do it. I just want all of my browsers in my Dock without adding 10 icons to it permanently. As a side note, I also set up an Open URL action in LeaderKey to do the same thing. When you hold down `⌘` while the browser picker is open, shortcut overlays appear on the browsers, so you can open any browser with a keyboard shortcut, making launching any browser keyboard-based. Of course, this is somewhat useless if you use a launcher like Alfred or LaunchBar, as your browsers are all a few keystrokes away anyway, so I’m just experimenting to see what my brain likes best… Like or share this post on Mastodon, Bluesky, or Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
06.03.2026 14:00 👍 0 🔁 0 💬 0 📌 0
Preview
Web Excursions for March 5th, 2026 Web excursions brought to you in partnership with Setapp. Get access to hundreds of Mac and iOS apps for one low monthly subscription fee. julienXX/terminal-notifier Send User Notifications on macOS from the command-line. A great shell script companion that doesn’t require making your own calls to `osascript`. giladdarshan/gdialog: Display macOS dialogs from terminal and scripts Another handy scripting tool to display macOS dialogs from terminal and scripts. There was a more involved version of this that I used to use years ago, but this fits the bill nicely for my current needs. steveyegge/beads > Beads - A memory upgrade for your coding agent. I haven’t quite gotten the hang of this yet, but I think it has a lot of potential. A persistent, structured memory for coding agents, replacing markdown plans with a dependency-aware graph to handle long-horizon tasks without losing context. Get Sh*t Done > A light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code and OpenCode. I’m not a Claude Code user, but for non-masochistic developers, this looks like an excellent tool. Check out Setapp today and get access to the best Mac and iOS apps out there. Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
05.03.2026 18:00 👍 0 🔁 0 💬 0 📌 0
Preview
A (slightly better?) linkding extension for Firefox and Chrome I’ve been using Sascha Ißbrücker’s extension for linkding with my own instance for a while now, and while it’s simple and works great, there’s one thing that always frustrated me: the popover window closes the moment you click outside of it, erasing what you’ve entered. Obviously, this will only be of use to people who: * Have a linkding server * Use Firefox or Chrome for general browsing If you fit the target audience, read on! Picture: you’re adding a bookmark, you’ve filled in the title and tags, maybe added some notes, and then you realize you need to copy something from the current page. Or maybe you want to paste some text from another window. The moment you click away to grab that text, _poof_ — the popover closes and you lose everything you’ve typed. ### The persistent popup solution So I created bt-linkding, a fork that opens the bookmark panel in a **persistent popup window** instead of the default toolbar popover. The window stays open until you explicitly click **Save** , **Update** , or **Cancel**. No more accidentally dismissing the window and losing your changes. This means you can freely navigate away from the bookmark panel to copy text from the current page, paste from another window, or do whatever else you need to do. Your work stays put until you’re ready to save it. The extension keeps all the features from the original like tag autocomplete, automatic page description detection, keyboard shortcuts (`Alt+Shift+L` to bookmark the current tab), and Omnibox search (type `ld` in the address bar). It just fixes that one annoying behavior. ### Building and installing Right now, the extension isn’t available in the browser stores (though I’ve submitted it to both Firefox and Chrome, but this is my first time submitting to either, so no promises about when or if it’ll be available there). For now, you’ll need to build and install it manually. The process is pretty straightforward. First, clone the repo and install dependencies: npm install Then build for your target browser. Firefox and Chrome require different background script formats, so you need to use the right build command: # For Firefox npm run build:firefox # For Chrome npm run build:chrome This updates the `manifest.json` file for the target browser. Once that’s done, you can load it as an unpacked extension. ### Installing in Firefox After running `npm run build:firefox`, open Firefox and navigate to `about:debugging`. Click “This Firefox” in the sidebar, then click “Load Temporary Add-on…”. Navigate to the project root directory and select any file in the extension (or just the `manifest.json` file). The extension will load and you’re good to go. ### Installing in Chrome After running `npm run build:chrome`, open Chrome and navigate to `chrome://extensions/`. Enable “Developer mode” (toggle in the top right), then click “Load unpacked”. Navigate to the project root directory and select it. The extension will load immediately. ### What’s next I’ve packaged the extension for both stores (`npm run package:firefox` and `npm run package:chrome` create the zip files), and I’ve submitted it to both. But since this is my first time going through the submission process for either store, I’m not making any promises about availability or timing. For now, building from source is the way to go. If the popover closing unexpectedly is a nit for you as well, give this fork a try. The persistent window makes a world of difference when you’re trying to add bookmarks with content from multiple sources. Full instructions and code are on GitHub. Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
04.03.2026 14:00 👍 0 🔁 0 💬 0 📌 0
Preview
Web Excursions for March 3rd, 2026 Web excursions brought to you in partnership with Backblaze. Back up everything. docmd — Minimalist Markdown Docs Generator > Generate beautiful, lightweight static documentation sites directly from your Markdown files with docmd. Zero clutter, just content. I’ve written multiple versions of this concept for myself, ranging from Jekyll plugin-based sites to wholly custom solutions using Apex. This is a very nice, plugin-capable solution that I look forward to trying out for my next documentation project. SourceDocs/SourceDocs: Generate Markdown documentation from source code As I get more into Swift development, automatic generation of documentation from source code comments is very nice. Kapeli/cheatset: Generate cheat sheets for Dash I recently had a pull request merged that fixed Cheatset for versions 2.7-4.x compatibility. If you want to make cheat sheets for Dash easily, this provides a DSL for doing so. I’ve incorporated it into several workflows, including a new one that uses Apex to convert Markdown documents into Dash cheatsheets that I’ll publish soon. Bartender 6 The latest builds of Bartender 6 bring it once again to the top of my list of menu bar managers. Super smooth on Tahoe, and beats Ice and Barbee for me now. Backblaze securely backs up your entire computer to the cloud, affordably and reliably. I trust it with all my data. Check it out today. Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
03.03.2026 18:00 👍 0 🔁 0 💬 0 📌 0
Preview
Generating Man Pages with Apex I’ve been using a combination of Pandoc and other tools to generate the man page for Apex from Markdown. As Apex nears a 1.0 release, I figured it should be able to generate man pages on its own. Man pages are what gets shown when you run `man COMMAND` in Terminal. They use `roff` formatting, which is a pain to write as a human. Writing documentation in Markdown is much easier. Apex can now turn your Markdown into roff man pages or styled HTML man docs. Two output formats cover both classic terminals and the web. **`-t man`** turns Markdown into roff (the traditional man-page source). Use it to generate `apex.1`, `my-tool.1`, and so on, then install them with `make install` or your package manager. No pandoc or go-md2man needed: after `make build`, `make man` runs the built `apex` binary with `-t man` to produce the man pages. Write your docs in Markdown and keep a single source for both the website and `man apex`. **`-t man-html`** turns the same Markdown into HTML. Without `-s` you get a content-only snippet (no wrapper, no nav), handy for embedding. With `-s` (standalone) you get a full page: fixed left sidebar TOC (NAME, SYNOPSIS, DESCRIPTION, etc.), a large headline from the NAME section, and optional custom CSS via `--css` or `--style`. The `document_title` metadata (e.g. for `APEX(1)`) is used when present. You can also pass `--code-highlight pygments` or `--code-highlight skylighting` to highlight code blocks in either snippet or standalone output. ### See it in action The Apex man page is available as HTML and as Markdown: * **HTML (standalone):** apex.1.html * **Markdown source:** apex.1.md Both are generated from the same Markdown; the HTML is what you get with `apex -t man-html -s` (plus any site styling). ### What else is new Highlights from the changelog: * **Man page creation:** `-t man` and `-t man-html` as above; Makefile uses `apex -t man` for man targets. * **`-t` / `--to` output formats:** html, json, json-filtered/ast-json/ast, markdown/md, mmd, commonmark/cmark, kramdown, gfm, terminal/cli, terminal256, and now man and man-html. * **Terminal and terminal256 renderers** with theme support, `--theme`, and user theme files in `~/.config/apex/terminal/themes/`. JSON and AST JSON output before and after filters for tooling. * **Terminal options:** `--width` for column wrap; `Terminal.width` and `Terminal.theme` metadata; theme `list_marker` style for bullets and numbers; `Span_classes` mapping for inline span classes. For the full list, see CHANGELOG.md. Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
02.03.2026 14:00 👍 0 🔁 0 💬 0 📌 0
Preview
Web Excursions for February 27th, 2026 Whether you’re a new user or a seasoned pro, ScreenCastsONLINE offers in-depth screencasts on a wide range of topics, from tutorials to app discovery. Check it out. DockFlow > Boost your productivity with DockFlow. Instantly save, manage, and switch between multiple macOS Docks. Interesting app. This is basically how Bunch started (albeit Bunch configuration is text-based). It lets you set up different Dock configurations for different contexts, which I like a lot. It is, in my opinion, overpriced for such a utility, but I also would never begrudge a developer charging what they think their work is worth. Who am I to say? OpenClaw Mac mini M4 Enclosure: Every powerful little crustacean needs a proper shell! | Product Hunt Just for fun. > The OpenClaw Mac mini M4 Enclosure is a fun, display-worthy 3D printed case for your OpenClaw/Clawdbot/Moltbot device. It’s a perfect blend of cute character + clean desk setup, turning your Mac mini into a chunky little desk companion. Dockey - Make your Dock faster A simple utility that basically offers GUI access to the animation delay and speed of the macOS Dock hide/show, avoiding the necessity of Terminal commands. Donationware. DockFix A reasonably-priced Dock enhancer with themes, custom icons, custom widgets, file shelf, and more. Want more great tips and apps? Check out ScreenCastsOnline. Like or share this post on Mastodon, Bluesky, or Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
27.02.2026 18:00 👍 0 🔁 0 💬 0 📌 0
Preview
A NYT Connections Helper I love playing the New York Times game Connections. Some days it’s easy, occasionally it stumps me. I don’t cheat at it — I’ll take a loss when I have to. What I _do_ do, though, is check the built-in hints to find out what order the categories are in. My goal is always to get the purple (hardest) first, which often involves figuring out at least the other three categories first. Once I know all four categories, the guessing game is just which one Connections thinks is hardest. 🔒 ### Premium Content Join to get the Connections script Subscribe Now I'm a Subscriber Cheat if you want to. Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
26.02.2026 12:00 👍 0 🔁 0 💬 0 📌 0
Preview
Web Excursions for February 25th, 2026 Web excursions brought to you in partnership with CleanMyMac X, all the tools to speed up your Mac, in one app. rhsev/grubber Another cool one from Ralf Hülsmann. Turn YAML front matter and YAML code blocks in a bunch of Markdown files into structured data you can search like a database. Like data view without Obsidian. Cursor Widget App A cool (free) idea that lets you connect a macOS desktop widget to Cursor using an MCP. What I’ll use it for is yet to be determined, but I like the idea. Right now I’m just having it reflect its current todo list to the widget, which is kinda handy, but I think I can do cooler stuff with it. What I learned building an opinionated and minimal coding agent > Lessons I learned while building my own coding agent from scratch. aryankashyap0/shorlabs Vercel for Backend. One-click deploy of Python and Node.js apps to AWS with no Docker knowledge needed. Mostly bookmarking this for my own future needs… Like or share this post on Mastodon, Bluesky, or Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
25.02.2026 12:36 👍 0 🔁 0 💬 0 📌 0
Preview
Web Excursions for February 20th, 2026 Web excursions brought to you in partnership with Backblaze. Back up everything. Devly — Developer Utilities The fastest way to encode, decode, format, hash, and convert — 50+ developer tools, one click from your menu bar. No subscriptions, no internet required. A nice companion to TextBuddy⏎. google webfonts helper Found this nifty tool for downloading very streamlined, compressed versions of Google fonts for self-hosting. Get eot, ttf, svg, woff and woff2 files + CSS snippets. giscus A comments widget built on GitHub Discussions. If I give up on running comments from my forum Discourse server at some point, this is what I’ll switch to. Cursor iThoughts integration One of my own design: teach Cursor to read iThoughts X mind maps and create implementation plans for them. Just place in .cursor/commands for the project and run `/ithoughts path/to/brainstorm.itmz`. Backblaze securely backs up your entire computer to the cloud, affordably and reliably. I trust it with all my data. Check it out today. Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
20.02.2026 11:58 👍 0 🔁 0 💬 0 📌 0
Preview
Apex and Multiple Image Formats Here’s part two of my Apex posts for the day: Multiple image formats from a single image syntax. Modern browsers support a bunch of image formats, like WebP and AVIF, that are smaller and sharper than good old PNG and JPEG. The trick is serving them without breaking older browsers. Apex makes that easy with a few attributes on your image syntax. Apex isn’t going to generate the additional images themselves, of course, that’s up to you. But if you have a series of images in the same directory, inserting them properly is super simple now. Take a directory with: * image.png * image@2x.png * image.webp * image@2x.webp * image.avif * image@2x.avif Now you can generate a `<picture>` (and optionally a `<figure>` with captions) with a single line of Markdown. ### WebP and AVIF Add `webp` or `avif` after the URL and Apex wraps the image in a `<picture>` element with the right `<source>` tags. Browsers that support the format use it; everyone else falls back to the main image. ![Hero image](img/hero.png webp) ![Hero AVIF](img/hero.png avif) You can combine both for maximum compatibility: ![Modern formats](img/banner.jpg webp avif) Both attributes work with `@2x` for retina. So `![Retina](img/hero.png webp @2x)` produces a srcset with `img.webp` at 1x and `img@2x.webp` at 2x. Same deal for avif. Apex also recognizes @3x, if you need that. ### Auto discovery If you’d rather not list every format by hand, use the `auto` attribute: ![Profile menu](img/app-pass-1-profile-menu.jpg auto) When `base_directory` is set (e.g. from the document path or `--base-directory`), Apex checks the filesystem for existing variants. For images it looks for 2x, 3x, webp, and avif. It only emits `<source>` elements for files that actually exist, so you can add formats over time without touching the Markdown. You can also use `*` as the extension, which does the same thing: !Profile menu That scans for jpg, png, gif, webp, and avif (plus 2x and 3x variants) for images, and mp4, webm, ogg, mov, and m4v for videos. `!` is equivalent to `![](image.png auto)`. ### Video: same syntax, different element Video URLs in image syntax get special treatment. If the URL ends in mp4, mov, webm, ogg, ogv, or m4v, Apex emits a `<video>` tag instead of `<img>`: !Demo video That becomes `<video><source src="https://brettterpstra.commedia/demo.mp4" type="video/mp4"></video>`. No extra syntax needed. To add alternative formats for broader browser support, tack on the format name: ![Demo with WebM](media/demo.mp4 webm) ![Demo with OGG](media/intro.mp4 ogg) Apex adds `<source>` elements for each format you specify. The primary URL (e.g. `demo.mp4`) stays as the fallback; the attributes add webm, ogg, or whatever before it. So `![](video.mp4 ogg)` gives you an ogg source plus the mp4 fallback. Like I said, using `auto` or `demo.*` as a video URL will generate markup for all the existing formats. ### What This Means It means proper, accessible markup, and optimized web pages. It won’t mean much if your output goal is PDF or DOCX, but it’s great for web production. The `auto` and `*` syntaxes mean you can write the Markdown once, and then every time you add a new format or resolution, the correct markup will be generated the next time you render, without touching the Markdown. ### Quick reference Syntax | Result ---|--- `![alt](url webp)` | `<picture>` with WebP source `![alt](url avif)` | `<picture>` with AVIF source `![alt](url webp @2x)` | WebP with retina srcset `![alt](url auto)` | Discovers formats from disk (requires base_directory) `!alt` | Same as auto—scans jpg/png/gif/webp/avif and video formats `!alt` | `<video>` with mp4 source `![alt](video.mp4 webm)` | `<video>` with webm + mp4 fallback Same Markdown image syntax, better output. Check out the Apex wiki for more details. Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
18.02.2026 16:05 👍 0 🔁 0 💬 0 📌 0
Preview
Apex as Terminal Markdown Renderer This is the first of two Apex posts today. In my opinion, the two things I’ve been working on deserve their own headlins. So here’s part one: terminal rendering. If you’ve ever wanted to read Markdown in the terminal with syntax highlighting and nice formatting, you’ve probably reached for **mdless** or **glow**. They’re great tools. But what if you want all of that _plus_ Apex’s extensions, filters, and plugins—tables with captions, footnotes, callouts, file includes, and whatever your custom filters do? That’s where Apex’s new built-in terminal output comes in. ## Triggering Terminal Output Apex can now render Markdown directly to your terminal instead of HTML. You use the `--output` flag with a special target: apex README.md --output cli or using the short form: apex README.md -o terminal Both produce colored, formatted output suitable for reading in an interactive terminal. No piping to mdless required—Apex handles it natively. For terminals that support 256 colors, use: apex README.md --output terminal256 This gives you a richer color palette for headings, code blocks, links, and other elements. If your terminal supports it, the difference is noticeable. ## What You Get Because this is Apex doing the rendering, you get the full pipeline: * **Extensions** : Tables, footnotes, definition lists, task lists, callouts, wiki links, and all the rest * **Filters** : Any filters in your config run before render, so your title filter, delink filter, or custom Lua scripts all apply * **Plugins** : Pre-parse and post-render plugins run as usual — kbd for `<kbd>` tags, md-fixup, or whatever you’ve installed So when you `apex doc.md -o terminal`, you’re seeing the same processed document you’d get as HTML, just rendered for the terminal instead. It’s not perfect for handling all elements yet, but the foundation is there and I’ll tweak it as needed. ## Theming: mdless Compatibility Apex’s terminal theming is compatible with mdless theme files. If you already use mdless, you can point Apex at your existing theme: apex doc.md -o terminal256 --theme ~/.config/mdless/mdless.theme Or set a default in your Apex config. The theme format matches mdless, so your `~/.config/mdless/mdless.theme` (or any `*.theme` file) works out of the box. Apex adds a few extra keys for elements mdless doesn’t have—callouts, definition lists, table captions, and the like. If you don’t define them, sensible defaults are used. You can override them in your theme file when you want finer control. ## JSON Output: Build Your Own Renderer Apex can also output a Pandoc-compatible JSON representation of the document: apex doc.md --output json This emits the full AST, including blocks, inlines, metadata, to stdout. From there you can pipe it into your own script to generate whatever you want: custom terminal formatting, a different HTML structure, plain text, or something else entirely. The JSON format is the same one filters use. So if you’ve written a filter, you already know the structure. A simple Python script can read it, walk the tree, and emit your own output format. No need to reimplement the parser. ## Quick Reference Option | Description ---|--- `-o cli` or `-o terminal` | Terminal output with basic ANSI colors `-o terminal256` | Terminal output with 256-color palette `-o json` | Raw Pandoc JSON AST for custom processing `--theme FILE` | Use mdless-compatible theme file So next time you’re browsing docs in the terminal, skip the Apex &rarr mdless/glow pipeline and let Apex do it all in one shot. Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
18.02.2026 16:00 👍 0 🔁 0 💬 0 📌 0
Preview
NiftyMenu Tahoe Edition NiftyMenu is the little tool I use to dump a macOS app’s menu bar into an HTML page so I can search it, click through to items, and add callouts for screencasts and product documentation. The latest round of updates brings the UI in line with macOS Tahoe and cleans up a bunch of behavior and script logic. Vimeo Video I wrote NiftyMenu years ago, and did a major update for Ventura. But it was way behind on Liquid Glass and basically wasn’t working anymore. ### What it is Since it’s been a while, let me explain NiftyMenu a bit. It’s a tool specifically for people documenting or blogging about apps (though anybody might get a kick out of it). When you want a screenshot of a menu item, highlighted (maybe with callouts), getting a menu to stick in place and maintain highlight state while you take a screenshot can be tricky, and doing a bunch of screenshots like this can be a pain. NiftyMenu creates an HTML version of any app’s menu bar. You can click any item to lock it in place, navigate submenus, select items, highlight them, add callouts, and focus the shortcut key. It has dark and light modes, quick search for finding menu items with just the keyboard, customizable desktop images (including random images), and more. I recommend viewing the pages it produces in Chrome. The styling should work well in any browser, but with Chrome you can just hit `⇧`+`S` to save a screenshot of the currently-focused menu item. It’s super handy. Screenshotting works in Firefox, but the text gets screwy. And it doesn’t work at all in Safari. So use Chrome/Chromium. At the bottom right of a generated menu page there’s a settings box that appears on hover, and you can change things like light/dark mode, background image, callout type, etc. NiftyMenu is a Ruby script that generates a web app of sorts. Just one page with all the markup necessary for the menu, and some CSS and JavaScript to enable all of its functionality. ### Styling to match Tahoe The new layout and colors are tuned to feel like Tahoe’s menu bar. The font stack uses SF Pro when available (Tahoe and recent macOS), with sensible fallbacks. Top-level menu items get a soft white overlay on hover with rounded corners instead of a flat accent block; the solid accent is reserved for the selected item (`.last`). Submenus use the same idea: light grey overlay on hover, accent only on the selected row. Toolbar height, padding, and submenu positioning are adjusted so the overall proportions match the system menus. Dark mode gets the same treatment: overlay-based hover states and accent only on the selected item. Submenu and divider colors use the new variables so they stay consistent with the rest of the theme. ### Backgrounds and overlays Unsplash deprecated their simpler API for image embeds, so I switched the random wallpaper generator to use Picsum.photos. You can’t do a search, but you can get a random image and apply blur and grayscale to it (NiftyMenu gives you the options when getting a random wallpaper). You can also add a “seed” word, which will stick the image so that it can be retrieved reliably. The seed you enter is stored in browser storage and will automatically be used next time the dialog is opened. The page background (gradient or desktop image) is now fixed to the viewport, so when the menu list is taller than the window you scroll the content, not the background. If you use a desktop image, there’s a brightening overlay: a semi-transparent layer with `background-blend-mode: overlay` so the image reads a bit lighter. You can tweak it (or turn it off) via `$desktop-image-overlay` and `$dark-desktop-image-overlay` in your variables. The toolbar strip at the top uses the same overlay color and blend so it sits nicely over the gradient or image. In light mode, when submenus overlap each other or the parent, the overlap darkens instead of lightening. Submenu `ul`s use `mix-blend-mode: multiply` and an opaque background so you get a clear light bluish-grey panel that darkens where it stacks. ### A few fixes while I was at it Divider rows no longer highlight on hover or accept clicks. They use `pointer-events: none` and the click handler bails out for divider targets and list items that only contain a divider. The script also fixes the case where a bogus shortcut was attached to a divider line and produced blockquotes in the markdown; that combo is now normalized to a plain divider span before processing. The globe key in shortcuts is no longer an emoji. The script outputs the entity, then swaps it for a `<span class="globe-icon">` so CSS can draw the icon with a mask and `currentColor`, including when the shortcut is in the callout (inverted) state. There’s a bit more progress reporting now, going to STDERR in Terminal while processing, with a simple bar and step labels: gather, process, convert, write. ### What’s missing In macOS, there are often menu items that change to an alternate option when you hold down the `⌥` key. With the way NiftyMenu gathers items, these all appear in the same menu, not hidden behind a modifier. I spent an hour this morning trying to get that to work, but it doesn’t seem feasible. I also struggled with overlapping transparencies. In Tahoe, when a submenu’s left edge overlaps the parent menu, the edge appears opaque, or at least at uniform transparency with the submenu. Because the HTML version uses CSS colors with alpha channels, the overlap instead becomes a brighter version of the base color. I played with blend modes and other tricks but couldn’t find a way to replicate this. It’s a very small issue, but one that bugged me. If anyone has suggestions, I’d love to hear them. There’s a JavaScript API for automating the process of selecting menu items using fuzzy search and even saving the screenshots, so you can create a workflow that generates new screenshots even as menu item names and positions change. It’s not a perfect solution — being able to do it with AppleScript would be better, but not an option here. I also could have, but didn’t, set it up so you could choose the OS styling to use. It’s always going to use the latest macOS (that it’s been updated for) and not be backward compatible. This seems like a reasonable approach to me. ### Give it a shot If you’re writing documentation or are a blogger who often talks about apps’ menu items and shortcuts, give NiftyMenu a shot. If nothing else, it’s a pretty cool proof of concept. Like or share this post on Mastodon, Bluesky, or Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
15.02.2026 18:57 👍 0 🔁 0 💬 0 📌 0
Preview
Pandoc-style filters for Apex In my quest to make Apex as complete as possible before I integrate it into Marked, I’ve added Pandoc-compatible filters, which can be written in Lua for native execution, or in any language using a Pandoc JSON pipeline. The filter system runs your code on the document _after_ parsing and _before_ rendering. The pipeline is: Markdown → AST → **Pandoc-style JSON** → your filters → JSON → HTML. That means you can transform the document in any language that speaks JSON — Ruby, Python, Lua, Node, whatever — using the same Pandoc JSON AST that Pandoc uses. If you’ve written a Pandoc filter, the proecess is the same: read one JSON document from stdin, write one JSON document to stdout. ### Running filters from the CLI Three main options: * **`--filter NAME`** — Run a single filter. Apex looks for an executable named `NAME` in your user filters directory (`~/.config/apex/filters` or `$XDG_CONFIG_HOME/apex/filters`). So `apex --filter title input.md` runs `~/.config/apex/filters/title`. These can exist inside of subdirectories. * **`--filters`** — Run _all_ executables in that directory, in sorted order. Handy if you keep a fixed pipeline (e.g. `01-title`, `10-delink`) and just want one flag. * **`--lua-filter FILE`** — Run a Lua script as a filter. Apex calls `lua FILE`; the script reads Pandoc JSON from stdin and writes JSON to stdout. You need a JSON library (e.g. **dkjson** : `luarocks install dkjson`). No Pandoc Lua runtime required. Filters run in sequence. If any filter exits non-zero or outputs invalid JSON, Apex aborts unless you pass `--no-strict-filters` (then it skips the bad filter and continues). ### The central filter list: install and list There’s a small index of filters at ApexMarkdown/apex-filters. It’s a single JSON file listing filter id, title, description, author, repo, etc. This will grow as I and others create new filters. * **`apex --list-filters`** — Prints “Installed Filters” (what’s in your `~/.config/apex/filters` dir) and “Available Filters” from that index, with titles and descriptions. * **`apex --install-filter ID`** — Installs a filter by id from the index (e.g. `apex --install-filter unwrap`). It clones the repo into your filters directory. You can also install by Git URL or GitHub shorthand (user/repo). * **`apex --uninstall-filter ID`** — Removes that filter (with a confirmation prompt). To add your own filter to the list, open a pull request on github.com/ApexMarkdown/apex-filters. Once it’s merged, everyone can `--list-filters` and `--install-filter your-id`. See the docs for more info on contributing. ### Example filters in the wild Two good references: * **ApexMarkdown/apex-filter-uppercase** — Lua filter that uppercases every `Str` inline. Shows how to walk the AST with dkjson and mutate in place. * **ApexMarkdown/apex-filter-unwrap** — Lua filter that unwraps elements starting with `< ` (e.g. custom block markers). More involved AST walking. Install them with `apex --install-filter uppercase` and `apex --install-filter unwrap`, then use `--filter uppercase` or `--filter unwrap` in your pipeline. ### Short Ruby example A minimal filter that reads Pandoc JSON, does one thing (e.g. prepend an H1 from metadata), and writes JSON back. Ruby’s stdlib `json` is enough. #!/usr/bin/env ruby require "json" doc = JSON.parse($stdin.read) blocks = doc["blocks"] || [] meta = doc["meta"] || {} # Get title from meta if present (simplified) title = meta.dig("title", "c") || "Untitled" title = title.is_a?(String) ? title : title.to_s header = { "t" => "Header", "c" => [1, ["", [], []], [{ "t" => "Str", "c" => title }]] } doc["blocks"] = [header] + blocks puts JSON.dump(doc) Save as `~/.config/apex/filters/title`, `chmod +x`, then `apex --filter title doc.md > out.html`. ### Short Lua example Same idea in Lua: read JSON, tweak `doc.blocks` (or `doc.meta`), write JSON. Requires `dkjson`. local json = require("dkjson") local input = io.read("*a") local doc = json.decode(input) if not doc then io.stderr:write("Invalid JSON\n") os.exit(1) end -- Optional: transform doc.blocks or doc.meta -- doc.blocks = ... io.write(json.encode(doc)) Run it with `apex --lua-filter myfilter.lua input.md > output.html`. ### Is This Feature Complete? No, but close. I can’t get to 100% Pandoc compatibility at this point without some restructuring of the AST generated by cmark-gfm. I’m not sure 100% parity is the goal at this point. So some existing Pandoc Lua filters require some updates to work with Apex. Additionally, this feature is literally what I came up with in two days and has a lot of testing ahead. If you’re willing to help, especially if you have Pandoc filters you’d like to port, please keep me posted on GitHub. ### Am I Trying to Replace Pandoc? No, really I’m not. I’m not trying to replicate Pandoc’s amazing export abilities, or many of its extensions. However, Pandoc’s relatively vast compatibility with various flavors of Markdown is similar to what I want to do in Apex, so supporting many of its extensions makes sense, and supporting filters means users who’ve written their own pipelines can easily switch to using Apex as a primary renderer. That’s going to be important if I want Marked to have Pandoc capabilities without using Pandoc as an external dependency. ### Learn more * Pandoc filters — The JSON AST format and filter contract Apex follows. * Apex wiki: Filters — Full protocol, block/inline types, Lua details, and more examples. Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
07.02.2026 15:36 👍 0 🔁 1 💬 0 📌 0
Preview
A trick for better naps <p><img src="https://cdn3.brettterpstra.com/uploads/2026/02/nap-routine-rb.7518.jpg" width="800" height="226" style="margin:0 auto;clear:both;" class="header" /></p> <p>I&rsquo;ve been sleeping anywhere between 4 and 6 hours a night, which isn&rsquo;t great because through trial and (mostly) error, I&rsquo;ve determined that at this point in my life, 8.5 hours of sleep is what I do best on. But naps help quite a bit.</p> <blockquote class="tip"> <p>By the way, if you want to follow my sleep journey as well as a couple of other neurodivergent people, along with fun pop culture and knowledge tech tidbits, you should listen to <a href="https://overtiredpod.com">Overtired</a>.</p> </blockquote> <p>As I&rsquo;ve <a href="https://brettterpstra.com/2026/02/04/project-updates-feb-4-2026-sleepless-edition/">mentioned before</a>, this sleep deprivation hasn&rsquo;t affected my coding productivity, but it&rsquo;s been a serious detriment to my social life, and affects my <a href="https://my.clevelandclinic.org/health/diseases/6004-dysautonomia">dysautonomia</a> (which I haven&rsquo;t talked about <a href="https://brettterpstra.com/2025/01/19/health-update-diagnoses/">for a while</a> but is still a major issue for me). overall pain levels pretty drastically. My only saving grace has been daily naps.</p> <h3 id="origin-of-my-routine">Origin of My Routine</h3> <p>I&rsquo;ve always taken naps, even when sleeping well. I find it very rejuvenating and my afternoons are way more productive if I nap about an hour after lunch.</p> <p>Once upon a time there was a startup called Napjitsu that sold a product appropriately called <em>Nap</em>. It was a combination of a chewable with mostly Valerian and 2 capsules containing mostly caffeine plus some other nootropics. You took them all at once. The chewable kicked in within 10 minutes, and the capsules metabolized about 30 minutes later, giving you a deep sleep for about 20 minutes and then waking up raring to go.</p> <p>I loved those, but whatever else was in the capsules often made me wake up a bit &ldquo;shaky.&rdquo; Napjitsu shuttered a while back, but I found alternatives. This version of the routine doesn&rsquo;t leave me shaky. To the contrary, I wake up feeling quite energetic without side effects.</p> <h3 id="scheduled-wakeup-call">Scheduled Wakeup Call</h3> <p>For the caffeine, I&rsquo;m using <a href="https://amzn.to/4a0zzo9">capsules with 50mg of caffeine &amp; 100mg of L-Theanine</a> (these are Amazon affiliate links, which are helpful to me, but you can find these direct in other places, and I would support you <em>not supporting</em> Amazon). I only take one of those, as that&rsquo;s enough to get me going without making me jittery or affecting my sleep later that night.</p> <p>You can really just drink a cup of coffee or a shot of espresso before laying down, as <a href="https://en.wikipedia.org/wiki/Caffeine">caffeine</a> in general takes 30 minutes to metabolize. But if you want the combination of caffeine and L-Theanine, that&rsquo;s basically tea. Just drink a cup of tea. (White or green tea will give you approximately the right amount of caffeine, with white tea capping out at 55mg and green tea capping out at 70mg.) The capsules aren&rsquo;t the key, just the caffeine.</p> <p>By the way, if you ever see guaranine as an ingredient in energy drinks or supplements, it&rsquo;s essentially just caffeine. <a href="https://en.wikipedia.org/wiki/Guarana">Guarana</a> is a plant, and guaranine is derived from it&rsquo;s seeds. It includes some other compounds, but the only one of note is caffeine.</p> <h3 id="sleep-phase">Sleep Phase</h3> <p>For the sleep phase of the nap, I tried a few things, but valerian root gives me the best results.</p> <p><a href="https://www.sleepfoundation.org/sleep-aids/valerian-root">Valerian root</a> is primarily indicated as a sleep aid. It&rsquo;s rumored to be good for stress, PMS, and anxiety, but there&rsquo;s no scientific evidence for those. It&rsquo;s also commonly used as a tea, so if you really want to drink a couple of cups of tea before laying down, that&rsquo;s an option, but you&rsquo;ll have to pee, what with liquids and diuretics and all. A need to urinate might help you get back up, too, but I personally dislike waking up like that.</p> <p>Velerian root offers me fast sedation, but easy to overcome with stimulants like caffeine, unlike supplements like melatonin. I&rsquo;m using gummies with <a href="https://amzn.to/3OnYUQm">6000mg of valerian root and chamomile</a>. I take 2-4 of those, depending on how overtired I am. I don&rsquo;t know about the safety of doubling the recommended serving size (2 gummies), but 4 does the trick on days I feel wired despite (or because of) lack of sleep.</p> <p>Weirdly, napping is easiest for me on days I actually <em>did</em> get enough sleep, which happens every 7-10 days. But on the days I most need a nap, a little supplemental cocktail is both necessary and effective.</p> <p>I don&rsquo;t know if this routine is a good idea, or how well it will work for others. I can&rsquo;t guarantee anything. But I can anecdotally endorse it as a decent way to &ldquo;force&rdquo; a restful nap that doesn&rsquo;t leave me dragging for the afternoon. If you want to nap but have trouble dozing off, or have trouble waking up, or both, this might be worth a try.</p> <h3 id="hows-your-sleep">How&rsquo;s Your Sleep?</h3> <p>I hope that&rsquo;s helpful to others 💤. Let me know <a href="https://forum.brettterpstra.com">in the forum</a> if you have any of your own tips, I&rsquo;m always looking for new ideas in this area.</p> <p>Like or share this post <a class="twitter" target="_blank" rel="nofollow" href="https://twitter.com/intent/tweet?original_referer=https%3A%2F%2Fbrettterpstra.com%2F2026%2F02%2F05%2Fa-trick-for-better-naps%2F&text=A+trick+for+better+naps&url=https%3A%2F%2Fbrettterpstra.com%2F2026%2F02%2F05%2Fa-trick-for-better-naps%2F&via=ttscoff" title="Tweet this post">Twitter</a>.</p> <hr style="margin:40px 0"> <p>BrettTerpstra.com is supported by readers like you. <a href="https://brettterpstra.com/support/">Click here if you'd like to help out.</a></p> <p>Find Brett on <a rel="me" href="https://hachyderm.io/@ttscoff">Mastodon</a>, <a href="https://bsky.app/profile/ttscoff.bsky.social">Bluesky</a>, <a href="https://github.com/ttscoff">GitHub</a>, and <a href="https://brettterpstra.com/elsewhere">everywhere else</a>.</p><img src="https://brett.trpstra.net/link/535/17270683.gif" height="1" width="1"/>
05.02.2026 18:51 👍 0 🔁 0 💬 0 📌 0
Preview
Project Updates Feb 4, 2026 — Sleepless Edition <p><img src="https://cdn3.brettterpstra.com/uploads/2026/02/project-updates-rb.7518.jpg" width="800" height="226" style="margin:0 auto;clear:both;" class="header" /></p> <p>I&rsquo;ve been hard at work on a bunch of projects at once, so this is partly just a personal check-in on how things are going. Thanks for being my therapist of sorts. So here&rsquo;s what&rsquo;s up with current projects, including Marked 3, Howzit, Apex, and more.</p> <ul class="toc"> <li>Table of Contents</li> </ul> <h3 id="marked-3">Marked 3</h3> <p><img src="https://cdn3.brettterpstra.com/uploads/2026/02/marked-icon.png"></p> <p>The <a href="https://markedapp.com/join-the-beta">Marked beta</a> continues. It&rsquo;s going really well, and I believe I&rsquo;ve fixed all crashes and made a bunch of improvements. One thing the next release will feature is better large document handling. This was important for dealing with BlogBook (see below) exports, but is also a vital quality of life improvement. Now the loading doesn&rsquo;t hang while reading 1MB+ files, and the rendering is faster for all processors.</p> <p>I added full tab handling (finally), with the option to open new documents in tabs. Plus a <a href="https://markedapp.com/help/Quick_Open">Quick Open</a> feature that lets you hit <span class="keycombo separated" title="Shift-Command-O"><kbd class="mod symbol">&#8679;</kbd><span class="keycombo combiner">+</span><kbd class="mod symbol">&#8984;</kbd><span class="keycombo combiner">+</span><kbd class="key symbol">O</kbd></span> and quickly type or use the arrow keys to jump to a window, tab, or recent document.</p> <p>I also included <a href="https://markedapp.com/help/Command_Line_Utility">a CLI</a> that makes accessing all of Marked&rsquo;s URL scheme methods easy from the command line. It can&rsquo;t yet automate a full export, but it&rsquo;s still useful. I&rsquo;ve refactored the whole export system to eventually allow full automation, but that&rsquo;s going to be a 3.1 feature, not something that holds up the 3.0 release.</p> <h3 id="blogbook">BlogBook</h3> <p><img src="https://cdn3.brettterpstra.com/uploads/2026/02/blogbook-icon.png"></p> <p>This is <a href="https://blogbook.my">a new app</a> I&rsquo;m publishing as a complement to Marked 3. It generates a &ldquo;Book&rdquo; from WordPress, Micro.blog, or Ghost blogs. You can filter posts by author, categories, tags, date ranges, and more, then export a Markdown document (or collection of documents with an index file) which you can open in any Markdown editor. If you open it in Marked 3, special syntax like Table of Contents and page breaks will be rendered, and you can output to PDF, HTML, EPUB, and more.</p> <p>This will be a great tool for people who have been blogging for a few (or a lot of) years who want a more permanent record of their work. Websites are known to disappear, but a good EPUB on your backup drive means your content will never be lost. Store your content as text, PDF, or EPUB for posterity, and/or distribute (or sell) a PDF/Ebook version of your writings. The filtering means you can easily export just a certain type of post, export only a certain date range, and you can even have the export split by year or month for making smaller books.</p> <p>Check out the documentation at <a href="https://blogbook.my">blogbook.my</a> to see what it looks like. If you&rsquo;re interested in helping me test, a TestFlight build will be available soon. It will be invite-only, so if you&rsquo;re interested and have a WordPress, Micro.blog, or Ghost blog, <a href="https://brettterpstra.com//contact">contact me directly</a> for an invite.</p> <h3 id="apex">Apex</h3> <p><img src="https://cdn3.brettterpstra.com/uploads/2026/02/apex-icon.png"></p> <p>The last few releases of Apex have mostly addressed issues, but I added a couple new things.</p> <p>First, I added support for Leanpub index syntax: <code class="language-plaintext highlighter-rouge">{i: term}</code>, <code class="language-plaintext highlighter-rouge">{i: "term"}</code>, and <code class="language-plaintext highlighter-rouge">{i: "Main!sub"}</code> for hierarchical entries under headings. This won&rsquo;t be of use to anyone but Leanpub authors, but adding it to the existing handling of mmark and TextIndex syntax was fairly trivial.</p> <p>Second, a feature I really like: if you add a <code class="language-plaintext highlighter-rouge">@2x</code> attribute anywhere in image attributes, e.g. <code class="language-plaintext highlighter-rouge">![ALT](/image.png @2x)</code>, it will generate an image with a srcset containing the original image at 1x, and then the image with @2x appended before the extension for retina displays. I also added support for <code class="language-plaintext highlighter-rouge">@3x</code>, which will generate srcset entries for 1x, 2x, and 3x variants of the same path.</p> <p>This srcset feature is one I&rsquo;ve always wanted, as I almost always provide @2x assets but Markdown never made it easy to generate the appropriate markup for these. I&rsquo;m doing my best not to add arbitrary syntax to Apex that doesn&rsquo;t exist anywhere else, but this one I really needed.</p> <p>I also did my first real-world conversion of a build system to Apex. The Marked documentation has always used MultiMarkdown, with a build script that manually converts a bunch of Liquid-style tags (like my <code class="language-plaintext highlighter-rouge">kbd</code> plugin and one that generates menu item notations that open Settings panes in Marked). I converted the whole thing to use Apex, and wrote project-level plugins for all of the custom syntax I wanted to handle. It worked perfectly and required no changes to my existing documentation. So I can vouch that Apex is a great tool for this kind of thing.</p> <p>I haven&rsquo;t pulled the trigger on integrating Apex into Marked yet, but thanks to some reported Issues, I&rsquo;ve got the Xcode integration working well. Apex is on the verge of a 1.0 release, at which point I <em>will</em> be converting Marked to use it. If you&rsquo;re a Mac or iOS developer of a Markdown app and are willing to try it out, I&rsquo;d love to help you integrate Apex and am very interested in getting some traction via inclusion in Mac/iOS apps. It&rsquo;s pure C, so if you&rsquo;re developing on other platforms, it will work just about anywhere, I just have the most interest in Apple platforms.</p> <h4 id="apex-fixes">Apex Fixes</h4> <ul> <li>TextIndex <code class="language-plaintext highlighter-rouge">[term]{^}</code> no longer includes the closing bracket in the index entry (e.g. &ldquo;fresh&rdquo; instead of &ldquo;fresh]&rdquo;).</li> <li>Reference-style image attributes (width, height, style, classes, id) are correctly applied in Unified, MultiMarkdown, and GFM modes, even when mixed with inline images and fenced div/figure blocks</li> </ul> <h3 id="howzit">Howzit</h3> <p>I made a simple update to <a href="https://brettterpstra.com/projects/howzit/" title="howzit">Howzit</a> to allow some interesting shell integration.</p> <p>The <code class="language-plaintext highlighter-rouge">--test-search</code> command just returns a zero exit code if:</p> <ol> <li>A build note file is found (<code class="language-plaintext highlighter-rouge">build*.{md,txt}</code>)</li> <li>The search string given matches at least one topic, based on configured search type</li> </ol> <p>If either of those fails, then it returns a non-zero exit code.</p> <p>This means you can have a <a href="https://github.com/ttscoff/howzit/wiki/Shell-integration#using-topics-as-shell-commands-fish-and-zsh">Fish or Zsh function for handling unknown commands</a> by testing for a matching topic and executing that if the command isn&rsquo;t found and a matching build note topic exists. So if I have a &ldquo;Run Tests&rdquo; topic in my build notes, and I run <code class="language-plaintext highlighter-rouge">runt</code> on the command line, the unknown command function will execute and will run <code class="language-plaintext highlighter-rouge">howzit -r runt</code>, which will match the &ldquo;Run Tests&rdquo; topic (if fuzzy matching is enabled).</p> <h3 id="wordpress-plugins">WordPress Plugins</h3> <p>I also added some localization to the <a href="https://brettterpstra.com/2026/02/02/a-couple-new-wordpress-plugins/">WordPress plugins</a> I shared the other day. No major changes to functionality, just language files for German, Spanish, French, and Italian. These were generated with AI, so I can&rsquo;t vouch for their accuracy. If any speakers of these languages care to look through the various PO files for <a href="https://github.com/ttscoff/bt-downloads/tree/main/bt-downloads/languages">bt-downloads</a> and <a href="https://github.com/ttscoff/bt-keyboard-shortcuts/tree/main/bt-keyboard-shortcuts/languages">bt-keyboard-shortcuts</a>, feel free to let me know (and I&rsquo;ll credit you in the documentation). Eventually when I get these published to the WordPress directory<sup id="fnref:wpdirectory"><a href="https://brettterpstra.com#fn:wpdirectory" class="footnote" rel="footnote" role="doc-noteref">1</a></sup>, this will be especially useful.</p> <p>I should probably at least add Simplified Chinese, too. I&rsquo;ll think about that. Any other language suggestions?</p> <h3 id="on-multitasking">On Multitasking</h3> <p>That pretty well sums up what I&rsquo;m working on right now. My sleep cycle is still only allowing me about 5 hours of sleep per night, and I&rsquo;m usually up coding by 3:30AM. And when I&rsquo;m really tired I have this weird ability to focus better while simultaneously multitasking, so most mornings I have 3 or 4 projects open at once and switch between them while one compiles or run tests or I just get bored. That part is kind of cool, though I&rsquo;d rather be sleeping, were that an option.</p> <p>The reason I keep adding to Howzit is because it&rsquo;s how I manage to jump between projects. With identically-named topics between the projects, when switching contexts I don&rsquo;t have to remember which build system I&rsquo;m supposed to use, I just use Howzit to develop, run tests, build/compile, and deploy. And taking copious notes in my build notes files means I can quickly query a topic and remember what exactly I was doing.</p> <p>And then there&rsquo;s <a href="https://brettterpstra.com//projects/doing">Doing</a>, which I have attached to git hooks in every repo. Every time I make a commit, it adds a Doing entry for me, so if at 4am I suddenly blank on what I was doing at 3:30, I can just run <code class="language-plaintext highlighter-rouge">doing since 3:30am</code> to see what I&rsquo;ve been working on. It&rsquo;s <em>super</em> handy, and since I&rsquo;m constantly committing to my git repos, I don&rsquo;t even have to worry about making manual Doing entries.</p> <p>Hopefully something in this update was of interest to you. Please keep me posted on what you&rsquo;re using (<a href="https://forum.brettterpstra.com">join us in the forum!</a>), what questions/issues/requests you have, and, as we we always say on Overtired, get some sleep, if you can.</p> <div class="footnotes" role="doc-endnotes"> <ol> <li id="fn:wpdirectory"> <p>My BT-SVG-Viewer plugin has been stuck in review for what feels like months, and you can only submit one plugin at a time.&nbsp;<a href="https://brettterpstra.com#fnref:wpdirectory" class="reversefootnote" role="doc-backlink">&#8617;</a></p> </li> </ol> </div> <p>Like or share this post <a class="twitter" target="_blank" rel="nofollow" href="https://twitter.com/intent/tweet?original_referer=https%3A%2F%2Fbrettterpstra.com%2F2026%2F02%2F04%2Fproject-updates-feb-4-2026-sleepless-edition%2F&text=Project+Updates+Feb+4%2C+2026+%E2%80%94+Sleepless+Edition&url=https%3A%2F%2Fbrettterpstra.com%2F2026%2F02%2F04%2Fproject-updates-feb-4-2026-sleepless-edition%2F&via=ttscoff" title="Tweet this post">Twitter</a>.</p> <hr style="margin:40px 0"> <p>BrettTerpstra.com is supported by readers like you. <a href="https://brettterpstra.com/support/">Click here if you'd like to help out.</a></p> <p>Find Brett on <a rel="me" href="https://hachyderm.io/@ttscoff">Mastodon</a>, <a href="https://bsky.app/profile/ttscoff.bsky.social">Bluesky</a>, <a href="https://github.com/ttscoff">GitHub</a>, and <a href="https://brettterpstra.com/elsewhere">everywhere else</a>.</p><img src="https://brett.trpstra.net/link/535/17269961.gif" height="1" width="1"/>
04.02.2026 15:24 👍 0 🔁 0 💬 0 📌 0
Preview
Karabiner Home row app switcher <p><img src="https://cdn3.brettterpstra.com/uploads/2026/02/keyboard-header-green-rb.7518.jpg" width="800" height="226" style="margin:0 auto;clear:both;" class="header" /></p> <p>Here&rsquo;s a new Karabiner-Elements trick that I&rsquo;ve been using to add home row app switching to my workflow. Tools like <a href="https://github.com/mikker/LeaderKey" title="mikker/LeaderKey:The *faster than your launcher* launcher">LeaderKey</a> are great, but sometimes you just want the App Switcher, and sometimes you just want to be lazy with your fingers.</p> <p>My goal was a home-row app switcher that triggered without leaving the home row, and didn&rsquo;t steal keys for normal typing. The solution is a Karabiner-Elements complex modification that uses <strong>simultaneous</strong> key detection: the layer only activates when <span class="keycombo separated" title="A"><kbd class="key symbol">A</kbd></span> and <span class="keycombo separated" title="S"><kbd class="key symbol">S</kbd></span> are pressed <em>together</em> within a very short window (basically at the exact same time).</p> <p><strong>This modification is designed for the <a href="https://www.keyboardmaestro.com/">Keyboard Maestro</a> app switcher</strong>, not the macOS default <span class="keycombo separated" title="Command-Tab Key"><kbd class="mod symbol">&#8984;</kbd><span class="keycombo combiner">+</span><kbd class="key symbol">&#8677;</kbd></span> switcher. Keyboard Maestro&rsquo;s switcher uses Shift alone to move backward, whereas the system switcher expects <span class="keycombo separated" title="Shift-Command-Tab Key"><kbd class="mod symbol">&#8679;</kbd><span class="keycombo combiner">+</span><kbd class="mod symbol">&#8984;</kbd><span class="keycombo combiner">+</span><kbd class="key symbol">&#8677;</kbd></span>; the key outputs below match KM&rsquo;s behavior, so you&rsquo;d need to adjust the modification (e.g. have <span class="keycombo separated" title="D"><kbd class="key symbol">D</kbd></span> send <span class="keycombo separated" title="Shift-Tab Key"><kbd class="mod symbol">&#8679;</kbd><span class="keycombo combiner">+</span><kbd class="key symbol">&#8677;</kbd></span>) to use it with the built-in app switcher.</p> <h2 id="what-it-does">What it does</h2> <p>Hold <span class="keycombo separated" title="A"><kbd class="key symbol">A</kbd></span> and <span class="keycombo separated" title="S"><kbd class="key symbol">S</kbd></span> at the same time. While both are held, you get a temporary layer:</p> <ul> <li><strong><span class="keycombo separated" title="D"><kbd class="key symbol">D</kbd></span></strong> &rarr; previous app (Shift only; KM uses this for backward)</li> <li><strong><span class="keycombo separated" title="F"><kbd class="key symbol">F</kbd></span></strong> &rarr; next app (<span class="keycombo separated" title="Command-Tab Key"><kbd class="mod symbol">&#8984;</kbd><span class="keycombo combiner">+</span><kbd class="key symbol">&#8677;</kbd></span>)</li> <li><strong><span class="keycombo separated" title="G"><kbd class="key symbol">G</kbd></span></strong> &rarr; quit (<span class="keycombo separated" title="Command-Q"><kbd class="mod symbol">&#8984;</kbd><span class="keycombo combiner">+</span><kbd class="key symbol">Q</kbd></span>)</li> </ul> <p>So you chord <span class="keycombo separated" title="A"><kbd class="key symbol">A</kbd></span>+<span class="keycombo separated" title="S"><kbd class="key symbol">S</kbd></span>, then tap <span class="keycombo separated" title="F"><kbd class="key symbol">F</kbd></span>, it loads the App Switcher, then tap <span class="keycombo separated" title="D"><kbd class="key symbol">D</kbd></span> or <span class="keycombo separated" title="F"><kbd class="key symbol">F</kbd></span> to move through the apps, or <span class="keycombo separated" title="G"><kbd class="key symbol">G</kbd></span> to quit the highlighted app. Release <span class="keycombo separated" title="A"><kbd class="key symbol">A</kbd></span> and/or <span class="keycombo separated" title="S"><kbd class="key symbol">S</kbd></span> and switch to the highlighted app.</p> <p>This works great with my <a href="https://brettterpstra.com/2025/03/24/keybindings-home-row-arrow-cluster/">home row arrow keys</a>, allowing easy up/down/left/right navigation through Keyboard Maestro&rsquo;s grid-based app switcher. Two-handed, but still all home row.</p> <h2 id="how-it-avoids-hijacking-a-and-s-for-regular-typing">How it avoids hijacking A and S for regular typing</h2> <p>The modification never intercepts a lone <span class="keycombo separated" title="A"><kbd class="key symbol">A</kbd></span> or <span class="keycombo separated" title="S"><kbd class="key symbol">S</kbd></span>. It only triggers when both keys are pressed <strong>simultaneously</strong>, with a 50 ms threshold (<code class="language-plaintext highlighter-rouge">basic.simultaneous_threshold_milliseconds</code>). When you type normally, you press one key, release it, then press another. Your &ldquo;a&rdquo; and &ldquo;s&rdquo; key events are separated by hundreds of milliseconds, so Karabiner never sees them as a chord. Only an intentional A+S chord activates the layer. Single-key &ldquo;a&rdquo; and &ldquo;s&rdquo; pass through unchanged.</p> <p>Under the hood, the rule uses Karabiner&rsquo;s <code class="language-plaintext highlighter-rouge">simultaneous</code> <code class="language-plaintext highlighter-rouge">from</code> condition: an array of key codes that must all be pressed together. On that chord it sets a variable (<code class="language-plaintext highlighter-rouge">as_layer</code>) and injects <span class="keycombo separated" title="Left_command"><kbd class="key symbol">left_command</kbd></span> so that the following D/F/G rules (which are gated on <code class="language-plaintext highlighter-rouge">as_layer</code>) produce the right modifiers. When either A or S is released, <code class="language-plaintext highlighter-rouge">to_after_key_up</code> clears the variable and the layer drops. So you keep full use of <span class="keycombo separated" title="A"><kbd class="key symbol">A</kbd></span> and <span class="keycombo separated" title="S"><kbd class="key symbol">S</kbd></span> for typing; only the deliberate two-finger chord turns them into a layer key.</p> <h2 id="import-this-modification">Import this modification</h2> <div class="biner-wrap" data-json-url="https://brettterpstra.com/karabiner/modifications/home-row-app-switcher.json"> <div class="biner-btn-group"> <a href="https://brettterpstra.comkarabiner://karabiner/assets/complex_modifications/import?url=https%3A%2F%2Fbrettterpstra.com%2Fkarabiner%2Fmodifications%2Fhome-row-app-switcher.json" class="biner-btn-main">Import to Karabiner</a> <button type="button" class="biner-btn-dropdown-toggle" aria-expanded="false" aria-haspopup="true" title="More options"> <span class="biner-chevron" aria-hidden="true">&#9660;</span> </button> </div> <div class="biner-dropdown-menu" role="menu" hidden=""> <button type="button" class="biner-dropdown-item biner-show-json" role="menuitem">Show JSON</button> <button type="button" class="biner-dropdown-item biner-copy-url" role="menuitem">Copy JSON URL</button> </div> </div> <script> (function(){ if (!window.BinerModal) { window.BinerModal = (function(){ var overlay, pre, currentText; function closeModal(){ if (overlay) { overlay.setAttribute("hidden", ""); overlay.setAttribute("aria-hidden", "true"); document.removeEventListener("keydown", escHandler); } } function escHandler(e){ if (e.key === "Escape") closeModal(); } function createModal() { overlay = document.createElement('div'); overlay.className = 'biner-modal-overlay'; overlay.setAttribute('aria-hidden', 'true'); overlay.innerHTML = '<div class="biner-modal-box" role="dialog" aria-modal="true" aria-labelledby="biner-modal-title">' + '<div class="biner-modal-header">' + '<h2 id="biner-modal-title" class="biner-modal-title">Modification JSON</h2>' + '<button type="button" class="biner-modal-close" aria-label="Close">&times;</button>' + '</div>' + '<div class="biner-modal-body">' + '<pre class="biner-modal-pre"></pre>' + '</div>' + '<div class="biner-modal-footer">' + '<button type="button" class="biner-modal-btn biner-modal-copy">Copy</button>' + '<button type="button" class="biner-modal-btn biner-modal-download">Download</button>' + '</div></div>'; pre = overlay.querySelector('.biner-modal-pre'); var style = document.createElement('style'); style.textContent = '.biner-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:10000;padding:20px;}.biner-modal-overlay[hidden]{display:none!important}.biner-modal-box{background:#fff;border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.2);max-width:90vw;max-height:85vh;display:flex;flex-direction:column;overflow:hidden}.biner-modal-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #eee}.biner-modal-title{margin:0;font-size:1.1rem;font-weight:600}.biner-modal-close{background:none;border:none;font-size:24px;line-height:1;cursor:pointer;color:#666;padding:0 4px}.biner-modal-close:hover{color:#000}.biner-modal-body{flex:1;overflow:auto;padding:16px}.biner-modal-pre{margin:0;font-family:monospace;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-all}.biner-modal-footer{padding:12px 16px;border-top:1px solid #eee;display:flex;gap:8px;justify-content:flex-end}.biner-modal-btn{padding:8px 16px;border-radius:6px;border:1px solid #ccc;background:#f5f5f5;cursor:pointer;font:inherit}.biner-modal-btn:hover{background:#e5e5e5}.biner-modal-copy:focus,.biner-modal-download:focus{outline:2px solid #007aff;outline-offset:2px}'; document.head.appendChild(style); overlay.querySelector('.biner-modal-close').addEventListener('click', closeModal); overlay.addEventListener('click', function(e){ if (e.target === overlay) closeModal(); }); overlay.querySelector('.biner-modal-copy').addEventListener('click', function(){ if (!currentText) return; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(currentText); } else { var ta = document.createElement('textarea'); ta.value = currentText; ta.setAttribute('readonly', ''); ta.style.position = 'absolute'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } finally { document.body.removeChild(ta); } } }); overlay.querySelector('.biner-modal-download').addEventListener('click', function(){ if (!currentText) return; var fn = (overlay.getAttribute('data-download-filename') || 'modification.json'); var blob = new Blob([currentText], { type: 'application/json' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = fn; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }); document.body.appendChild(overlay); } return { show: function(jsonUrl) { if (!overlay) createModal(); overlay.removeAttribute('hidden'); overlay.setAttribute('aria-hidden', 'false'); document.addEventListener('keydown', escHandler); pre.textContent = 'Loading…'; currentText = null; overlay.setAttribute('data-download-filename', (jsonUrl.split('/').pop() || 'modification.json')); fetch(jsonUrl).then(function(r){ if (!r.ok) throw new Error(r.status); return r.text(); }).then(function(text){ try { text = JSON.stringify(JSON.parse(text), null, 2); } catch (e) {} currentText = text; pre.textContent = text; }).catch(function(){ pre.textContent = 'Failed to load JSON.'; }); } }; })(); } var wrap = document.currentScript.previousElementSibling; var toggle = wrap.querySelector('.biner-btn-dropdown-toggle'); var menu = wrap.querySelector('.biner-dropdown-menu'); var jsonUrl = wrap.getAttribute('data-json-url'); function closeMenu(){ menu.hidden = true; toggle.setAttribute('aria-expanded', 'false'); } function openMenu(){ menu.hidden = false; toggle.setAttribute('aria-expanded', 'true'); } toggle.addEventListener('click', function(e){ e.preventDefault(); e.stopPropagation(); menu.hidden ? openMenu() : closeMenu(); }); wrap.querySelector('.biner-show-json').addEventListener('click', function(){ closeMenu(); window.BinerModal.show(jsonUrl); }); wrap.querySelector('.biner-copy-url').addEventListener('click', function(){ if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(jsonUrl).then(closeMenu); } else { var ta = document.createElement('textarea'); ta.value = jsonUrl; ta.setAttribute('readonly', ''); ta.style.position = 'absolute'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); closeMenu(); } catch (err) {} document.body.removeChild(ta); } }); document.addEventListener('click', function(e){ if (!wrap.contains(e.target)) closeMenu(); }); })(); </script> <div id="paywall_8743d3287caa2902" class="paywall-container" data-paywall-id="paywall_8743d3287caa2902" data-content-id="8743d3287caa2902"> <div class="paywall-content-placeholder" style="display: none;"></div> <div class="paywall-overlay"> <div class="paywall-message"> <div class="paywall-icon">🔒</div> <h3>Premium Content</h3> <p>And More</p> <div class="paywall-actions"> <a href="https://brettterpstra.com//support/" class="paywall-subscribe-btn">Subscribe Now</a> <button class="paywall-login-btn" onclick="paywallLogin()">I'm a Subscriber</button> </div> </div> </div> </div> <script> // Set up global paywall config and registration queue (function() { if (!window.paywallConfig) { window.paywallConfig = { serviceUrl: 'https://paywall.brettterpstra.com', cookieName: 'bt_member_token', instances: [] }; } // Register this instance (collected by bt.Paywall when it loads) window.paywallConfig.instances.push({ id: 'paywall_8743d3287caa2902', contentId: '8743d3287caa2902' }); })(); </script> <p>Like or share this post <a title="This post on Mastodon" target="_blank" href="https://hachyderm.io/users/ttscoff/statuses/116007497228558733">on Mastodon</a>, <a title="Share on Bluesky" target="_blank" href="https://bsky.app/intent/compose?text=Karabiner+Home+row+app+switcher%0A%0Ahttps%3A%2F%2Fbrettterpstra.com%2F2026%2F02%2F03%2Fhome-row-app-switcher%2F">Bluesky</a>, or <a class="twitter" target="_blank" rel="nofollow" href="https://twitter.com/intent/tweet?original_referer=https%3A%2F%2Fbrettterpstra.com%2F2026%2F02%2F03%2Fhome-row-app-switcher%2F&text=Karabiner+Home+row+app+switcher&url=https%3A%2F%2Fbrettterpstra.com%2F2026%2F02%2F03%2Fhome-row-app-switcher%2F&via=ttscoff" title="Tweet this post">Twitter</a>.</p> <hr style="margin:40px 0"> <p>BrettTerpstra.com is supported by readers like you. <a href="https://brettterpstra.com/support/">Click here if you'd like to help out.</a></p> <p>Find Brett on <a rel="me" href="https://hachyderm.io/@ttscoff">Mastodon</a>, <a href="https://bsky.app/profile/ttscoff.bsky.social">Bluesky</a>, <a href="https://github.com/ttscoff">GitHub</a>, and <a href="https://brettterpstra.com/elsewhere">everywhere else</a>.</p><img src="https://brett.trpstra.net/link/535/17269248.gif" height="1" width="1"/>
03.02.2026 15:23 👍 0 🔁 0 💬 0 📌 0
Preview
A couple new WordPress plugins <p><img src="https://cdn3.brettterpstra.com/uploads/2026/02/wordpress-plugins-header-rb.7518.jpg" width="800" height="226" style="margin:0 auto;clear:both;" class="header" /></p> <p>Just for fun, I&rsquo;ve been porting some of my Jekyll plugins to WordPress, and two of them have turned into what I think are really useful plugins.</p> <p>I&rsquo;m not switching from Jekyll to WordPress, I&rsquo;ve just been enjoying the challenge of recreating these tools as WordPress extensions. It&rsquo;s somewhat funny because the Jekyll plugins were originally created from WordPress plugins I&rsquo;d built, and they developed over time. So this is kind of a round trip.</p> <h3 id="bt-downloads">BT Downloads</h3> <p><a href="https://brettterpstra.com//projects/bt-downloads">Project page</a></p> <p>A plugin for managing downloadables and dropping download cards into posts. You get a custom post type for each download (file URL, version, description, info link, icon, changelog), upload buttons for the file and icon right on the edit screen, and an editable HTML card template with Mustache-style conditionals (<code class="language-plaintext highlighter-rouge">{{#description}}...{{/description}}</code>) plus custom CSS with a live preview.</p> <p><img src="https://cdn3.brettterpstra.com/uploads/2026/02/downloads-800.jpg"></p> <p>Insert them via a TinyMCE button in the classic editor or a <strong>Download</strong> block in the block editor. Pick from a dropdown and the shortcode (or block) is inserted for you. On the frontend you get a styled card: title, download link, description, dates, and optional donate/info links. There&rsquo;s also a WP-CLI command to import from CSV (the way I handle downloads in Jekyll): <code class="language-plaintext highlighter-rouge">wp btdl import_downloads --file=/path/to/downloads.csv</code> . Full details, more screenshots, and the shortcode reference are on the <a href="https://brettterpstra.com//projects/bt-downloads">BT Downloads project page</a>.</p> <h3 id="bt-keyboard-shortcuts">BT Keyboard Shortcuts</h3> <p><a href="https://brettterpstra.com//projects/bt-keyboard-shortcuts">Project page</a></p> <p>This one is for writing keyboard shortcuts in posts without hand-coding symbols. I&rsquo;ve created a few variations of this over time: <a href="https://brettterpstra.com/2021/06/22/a-jekyll-plugin-for-documenting-mac-keyboard-shortcuts/">for this blog</a>, for Marked and Bunch documentation, and probably others. A shortcode <code class="language-plaintext highlighter-rouge">[kbd]</code> renders things like <span class="keycombo separated" title="Option-Shift-Command-A"><kbd class="mod symbol">&#8997;</kbd><span class="keycombo combiner">+</span><kbd class="mod symbol">&#8679;</kbd><span class="keycombo combiner">+</span><kbd class="mod symbol">&#8984;</kbd><span class="keycombo combiner">+</span><kbd class="key symbol">A</kbd></span> in the order Apple recommends, with options for symbol vs text (e.g. &ldquo;Command-Shift-P&rdquo;), a + separator, and Mac vs Windows naming.</p> <p><img src="https://cdn3.brettterpstra.com/uploads/2026/02/kbd-shortcut-editor.jpg"></p> <p>In the block or classic editor, use the formatting menu and choose <strong>⌘ Insert keyboard shortcut</strong> to open a dialog: check modifiers (Cmd/Opt/Shift/Ctrl/Fn), type the key, and insert the shortcode. Under <strong>Settings → Keyboard Shortcuts</strong> you can tweak display (symbols vs text, + separator, key symbols) and add custom CSS for the <code class="language-plaintext highlighter-rouge">.btkbd</code> keycaps, with a live preview. Examples: <code class="language-plaintext highlighter-rouge">[kbd cmd shift p]</code> &rarr; <span class="keycombo separated" title="Shift-Command-P"><kbd class="mod symbol">&#8679;</kbd><span class="keycombo combiner">+</span><kbd class="mod symbol">&#8984;</kbd><span class="keycombo combiner">+</span><kbd class="key symbol">P</kbd></span>, <code class="language-plaintext highlighter-rouge">[kbd$@P]</code> → <span class="keycombo separated" title="Shift-Command-P"><kbd class="mod symbol">&#8679;</kbd><span class="keycombo combiner">+</span><kbd class="mod symbol">&#8984;</kbd><span class="keycombo combiner">+</span><kbd class="key symbol">P</kbd></span>. More syntax and options are on the <a href="https://brettterpstra.com//projects/bt-keyboard-shortcuts">BT Keyboard Shortcuts project page</a>.</p> <p><img src="https://cdn3.brettterpstra.com/uploads/2026/02/kbd-post.jpg"></p> <h3 id="repo-and-requirements">Repo and requirements</h3> <p>Both require WordPress 5.8+ and PHP 7.4+, and are GPLv2 or later. The source for these and any future WordPress plugins from me is my <a href="https://github.com/ttscoff/wordpress-plugins">wordpress-plugins repo on GitHub</a>. Grab the code there or follow the install steps on each project page.</p> <p>Like or share this post <a class="twitter" target="_blank" rel="nofollow" href="https://twitter.com/intent/tweet?original_referer=https%3A%2F%2Fbrettterpstra.com%2F2026%2F02%2F02%2Fa-couple-new-wordpress-plugins%2F&text=A+couple+new+WordPress+plugins&url=https%3A%2F%2Fbrettterpstra.com%2F2026%2F02%2F02%2Fa-couple-new-wordpress-plugins%2F&via=ttscoff" title="Tweet this post">Twitter</a>.</p> <hr style="margin:40px 0"> <p>BrettTerpstra.com is supported by readers like you. <a href="https://brettterpstra.com/support/">Click here if you'd like to help out.</a></p> <p>Find Brett on <a rel="me" href="https://hachyderm.io/@ttscoff">Mastodon</a>, <a href="https://bsky.app/profile/ttscoff.bsky.social">Bluesky</a>, <a href="https://github.com/ttscoff">GitHub</a>, and <a href="https://brettterpstra.com/elsewhere">everywhere else</a>.</p><img src="https://brett.trpstra.net/link/535/17269249.gif" height="1" width="1"/>
02.02.2026 19:00 👍 0 🔁 0 💬 0 📌 0
Preview
Web Excursions for February 2nd, 2026 <p><img src="https://cdn3.brettterpstra.com/uploads/2017/03/web-exc-map.jpg"></p> <p>Whether you&rsquo;re a new user or a seasoned pro, ScreenCastsONLINE offers in-depth screencasts on a wide range of topics, from tutorials to app discovery. <a href="https://screencastsonline.com/members/aff/go/bterpstra">Check it out.</a></p> <dl> <dt><a href="https://iloveapiano.app/">I Love a Piano</a></dt> <dd>I met Max Mellman at Macstock and we became fast friends. His first app is a piano synth with multiple on-screen scrollable keyboards, different piano sounds, and a built in music player with fine scrubbing for learning along with your favorite music.</dd> <dt><a href="https://www.amazon.com/Lawyers-Guide-Podcasting-Tech-Savvy-Lawyer-Pages/dp/B0GGX32DZH#detailBullets_feature_div">The Lawyer&rsquo;s Guide to Podcasting</a></dt> <dd>A book from my friend Michael D.J. Eisenberg ((The Tech-Savvy Lawyer): The Lawyer&rsquo;s Guide to Podcasting: Building Your Brand, Audience, Tech Stack, and Expertise!</dd> <dt><a href="https://github.com/rhsev/mi.lan">rhsev/mi.lan:</a></dt> <dd>A lightweight URL bridge for macOS automation, and a companion for <a href="https://github.com/rhsev/dy.lan">dy.lan</a>, which I <a href="https://brettterpstra.com/2026/01/27/a-url-router-that-turns-your-local-network-into-a-workflow-engine/">wrote about last week</a>.</dd> <dt><a href="https://apps.apple.com/us/app/vimari/id1480933944?mt=12">‎Vimari App - App Store</a></dt> <dd>A port of Vimium for Safari. Vimium is one of my favorite extensions on Chrome and Firefox, and I&rsquo;d always missed it on Safari, not realizing someone had already ported it.</dd> <dt><a href="https://apps.apple.com/us/app/linkthing/id1666031776">‎LinkThing</a></dt> <dd>Not perfect, but if you need to view and search your linkding bookmarks, this runs on all Apple platforms and does the trick.</dd> <dt><a href="https://github.com/moltbot/moltbot">moltbot/moltbot: Your own personal AI assistant.</a></dt> <dd>A personal chatbot assistant for any OS and any platform. I think this is what I&rsquo;m going to do with one of the 2012 Mac minis I picked up for $25.</dd> <dt><a href="https://github.com/steveyegge/gastown">Gas Town - multi-agent workspace manager</a></dt> <dd>We talked about this at some length on the <a href="https://overtiredpod.com/ep/442/">last Overtired</a>. Run multiple AI coding agents simultaneously with lots of useful features. Not cheap to implement fully, but powerful.</dd> <dt><a href="https://updates.techforpalestine.org/upscrolled-is-live-the-instagram-alternative-thats-actually-on-your-side/">UpScrolled is live</a></dt> <dd> <blockquote> <p>If you&rsquo;ve been waiting for a real alternative to Instagram, this is it.</p> </blockquote> </dd> <dd> <p>From a Palestinian-Australian developer and supported by Tech for Palestine, a solid looking alternative to Instagram that allows text updates and no shadow banning/censorship. Right now it&rsquo;s mostly my source for news from Gaza, but I&rsquo;m curious to see how it grows. They&rsquo;ve already hit limitations of scale and are updating rapidly.</p> </dd> </dl> <p>Want more great tips and apps? Check out <a href="https://screencastsonline.com/members/aff/go/bterpstra">ScreenCastsOnline</a>.</p> <p>Like or share this post <a class="twitter" target="_blank" rel="nofollow" href="https://twitter.com/intent/tweet?original_referer=https%3A%2F%2Fbrettterpstra.com%2F2026%2F02%2F02%2Fweb-excursions-for-february-2nd-2026%2F&text=Web+Excursions+for+February+2nd%2C+2026&url=https%3A%2F%2Fbrettterpstra.com%2F2026%2F02%2F02%2Fweb-excursions-for-february-2nd-2026%2F&via=ttscoff" title="Tweet this post">Twitter</a>.</p> <hr style="margin:40px 0"> <p>BrettTerpstra.com is supported by readers like you. <a href="https://brettterpstra.com/support/">Click here if you'd like to help out.</a></p> <p>Find Brett on <a rel="me" href="https://hachyderm.io/@ttscoff">Mastodon</a>, <a href="https://bsky.app/profile/ttscoff.bsky.social">Bluesky</a>, <a href="https://github.com/ttscoff">GitHub</a>, and <a href="https://brettterpstra.com/elsewhere">everywhere else</a>.</p><img src="https://brett.trpstra.net/link/535/17268512.gif" height="1" width="1"/>
02.02.2026 16:14 👍 0 🔁 0 💬 0 📌 0
Preview
Project-level plugins and config for Apex <p><img src="https://cdn3.brettterpstra.com/uploads/2025/12/apex-header-2-rb.7517.jpg" width="800" height="226" style="margin:0 auto;clear:both;" class="header" /></p> <p>If you use Apex for more than one project, you&rsquo;ve probably hit the point where a single global setup doesn&rsquo;t quite cut it. Maybe your book project wants a different plugin set than your docs site, or one repo has stricter defaults than everything else on your machine.</p> <p>As a personal example, most of my app documentation has always been written in MultiMarkdown, where header ids get generated with no spaces or dashes, so all of my cross-references link to <code class="language-plaintext highlighter-rouge">#thissection</code> type of anchors. My blog and my Jekyll-based documentation sites have always used Kramdown, so header ids and cross references are <code class="language-plaintext highlighter-rouge">#this-section</code>. I needed an easy way to always have Apex use the right header format for the current project.</p> <p>I also have special plugins for different destinations. For example, my Marked documentation has special Liquid-style tags like <code class="language-plaintext highlighter-rouge">prefpane</code> that generates nice HTML for referencing Preference panes with <code class="language-plaintext highlighter-rouge">x-marked</code> URLs that will open a preference pane directly in Marked. I don&rsquo;t need or want a plugin to do that universally, the output it generates is very specific to Marked.</p> <p>So I added project-scoped plugins and configurations to Apex. This allows me to get the settings just right for a project, then save them into a local directory and be able to just run <code class="language-plaintext highlighter-rouge">apex</code> without a bunch of command line flags to remember.</p> <p>You also get a cleaner way to &ldquo;shadow&rdquo; plugins you don&rsquo;t want with a local noop plugin.</p> <blockquote> <p>I also added <code class="language-plaintext highlighter-rouge">++insert++</code> syntax for adding <code class="language-plaintext highlighter-rouge">&lt;ins&gt;insert&lt;/ins&gt;</code> tags, but that&rsquo;s just a little one-off addition.</p> </blockquote> <h3 id="project-scoped-plugins-in-apexplugins">Project-scoped plugins in <code class="language-plaintext highlighter-rouge">.apex/plugins</code></h3> <p>Plugins used to be purely global: Apex would only look in your XDG config dir:</p> <ul> <li><code class="language-plaintext highlighter-rouge">$XDG_CONFIG_HOME/apex/plugins</code>, or</li> <li><code class="language-plaintext highlighter-rouge">~/.config/apex/plugins</code></li> </ul> <p>Now there&rsquo;s a proper <strong>project scope</strong>, searched in this order:</p> <ol> <li><code class="language-plaintext highlighter-rouge">./.apex/plugins</code> (current working directory)</li> <li><code class="language-plaintext highlighter-rouge">BASE/.apex/plugins</code> when you run with <code class="language-plaintext highlighter-rouge">--base-dir BASE</code></li> <li><code class="language-plaintext highlighter-rouge">&lt;git-root&gt;/.apex/plugins</code> when you&rsquo;re inside a Git work tree</li> <li>Global: <code class="language-plaintext highlighter-rouge">$XDG_CONFIG_HOME/apex/plugins</code> or <code class="language-plaintext highlighter-rouge">~/.config/apex/plugins</code></li> </ol> <p>Each of those directories can hold Apex plugins in the usual format:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight fixed"><code>.apex/ plugins/ my-plugin/ plugin.yml whatever-script-you-like</code></pre></div></div> <p>When Apex builds the plugin list, <strong>earlier locations win by id</strong>. If a plugin with id <code class="language-plaintext highlighter-rouge">footnotes-plus</code> exists in both <code class="language-plaintext highlighter-rouge">.apex/plugins</code> and your global config dir, the project version is the one that runs.</p> <h3 id="no-op-shadowing-turning-off-plugins-per-project">No-op shadowing: turning off plugins per project</h3> <p>That id-based precedence also gives you a neat trick: <strong>no-op shadowing</strong>.</p> <p>If there&rsquo;s a global plugin you usually like, but you don&rsquo;t want it in a specific project, you can &ldquo;shadow&rdquo; it by dropping an empty or no-op plugin with the same id into <code class="language-plaintext highlighter-rouge">.apex/plugins</code>. For example:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight fixed"><code>.apex/ plugins/ kbd/ plugin.yml noop.sh</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">plugin.yml</code> might look like:</p> <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight fixed"><code><span class="na">id</span><span class="pi">:</span> <span class="s">kbd</span> <span class="na">title</span><span class="pi">:</span> <span class="s">KBD Noop</span></code></pre></div></div> <p>Because the project copy of <code class="language-plaintext highlighter-rouge">kbd</code> is discovered first, it <strong>shadows</strong> the global one. You can also do the same trick with purely declarative regex plugins: define a plugin with the same id that simply doesn&rsquo;t match anything meaningful, and the global behavior is effectively disabled for that project.</p> <h3 id="--list-plugins-now-understands-projects"><code class="language-plaintext highlighter-rouge">--list-plugins</code> now understands projects</h3> <p>To make this discoverable, <code class="language-plaintext highlighter-rouge">apex --list-plugins</code> was updated to use the <strong>same resolution rules</strong> as the runtime plugin loader.</p> <p>When you run:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight fixed"><code>apex <span class="nt">--list-plugins</span></code></pre></div></div> <p>you&rsquo;ll see:</p> <ul> <li>An &ldquo;Installed Plugins&rdquo; section that includes plugins from: <ul> <li><code class="language-plaintext highlighter-rouge">./.apex/plugins</code></li> <li><code class="language-plaintext highlighter-rouge">BASE/.apex/plugins</code> (if <code class="language-plaintext highlighter-rouge">--base-dir</code> was used)</li> <li><code class="language-plaintext highlighter-rouge">&lt;git-root&gt;/.apex/plugins</code></li> <li>global config plugins</li> </ul> </li> <li>An &ldquo;Available Plugins&rdquo; section from the remote directory, <strong>filtered</strong> so remote entries are hidden when you already have a plugin with the same id installed anywhere (project or global).</li> </ul> <p>If a project plugin shadows a global one, you&rsquo;ll only see the project entry in the installed list, and the remote listing won&rsquo;t try to &ldquo;helpfully&rdquo; re-offer the same id.</p> <h3 id="project-level-config-in-apexconfigyml">Project-level config in <code class="language-plaintext highlighter-rouge">.apex/config.yml</code></h3> <p>Plugins aren&rsquo;t the only thing that benefit from scoping. Apex&rsquo;s configuration system now has an explicit <strong>project layer</strong>, alongside the existing global and per-document metadata.</p> <p>Config is now read from these places:</p> <ol> <li><strong>Global config</strong> <ul> <li><code class="language-plaintext highlighter-rouge">$XDG_CONFIG_HOME/apex/config.yml</code>, or</li> <li><code class="language-plaintext highlighter-rouge">~/.config/apex/config.yml</code></li> </ul> </li> <li><strong>Project config</strong> <ul> <li><code class="language-plaintext highlighter-rouge">./.apex/config.yml</code></li> <li><code class="language-plaintext highlighter-rouge">BASE/.apex/config.yml</code> when using <code class="language-plaintext highlighter-rouge">--base-dir BASE</code></li> <li><code class="language-plaintext highlighter-rouge">&lt;git-root&gt;/.apex/config.yml</code> when inside a Git work tree</li> </ul> </li> <li><strong>Explicit metadata file</strong> <ul> <li>Any file you pass with <code class="language-plaintext highlighter-rouge">--meta-file FILE</code></li> </ul> </li> <li><strong>Per-document metadata</strong> <ul> <li>YAML front matter, MultiMarkdown metadata, or Pandoc title blocks</li> </ul> </li> <li><strong>Command-line metadata and flags</strong> <ul> <li><code class="language-plaintext highlighter-rouge">--meta KEY=VALUE</code>, <code class="language-plaintext highlighter-rouge">--mode</code>, <code class="language-plaintext highlighter-rouge">--pretty</code>, <code class="language-plaintext highlighter-rouge">--no-tables</code>, and so on</li> </ul> </li> </ol> <p>The merge order matters:</p> <ul> <li>Global <code class="language-plaintext highlighter-rouge">config.yml</code> (lowest file precedence)</li> <li>Project <code class="language-plaintext highlighter-rouge">.apex/config.yml</code></li> <li><code class="language-plaintext highlighter-rouge">--meta-file FILE</code></li> <li>Document metadata</li> <li><code class="language-plaintext highlighter-rouge">--meta</code> and CLI flags (highest precedence)</li> </ul> <p>So if you put this in your <strong>global</strong> config:</p> <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight fixed"><code><span class="na">mode</span><span class="pi">:</span> <span class="s">unified</span> <span class="na">pretty</span><span class="pi">:</span> <span class="no">true</span> <span class="na">wikilinks</span><span class="pi">:</span> <span class="no">false</span></code></pre></div></div> <p>and then in your <strong>project</strong> <code class="language-plaintext highlighter-rouge">.apex/config.yml</code>:</p> <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight fixed"><code><span class="na">wikilinks</span><span class="pi">:</span> <span class="no">true</span> <span class="na">indices</span><span class="pi">:</span> <span class="no">true</span></code></pre></div></div> <p>you&rsquo;ll end up with:</p> <ul> <li><code class="language-plaintext highlighter-rouge">mode: unified</code></li> <li><code class="language-plaintext highlighter-rouge">pretty: true</code></li> <li><code class="language-plaintext highlighter-rouge">wikilinks: true</code> # project overrides global</li> <li><code class="language-plaintext highlighter-rouge">indices: true</code> # project-only addition</li> </ul> <p>Any <code class="language-plaintext highlighter-rouge">--meta-file</code> you pass on the command line layers on top of both, and document/CLI overrides still win last.</p> <h3 id="a-quick-example-project-layout">A quick example project layout</h3> <p>Here&rsquo;s what a repo might look like with all of this wired up:</p> <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight fixed"><code>my-book/ .git/ .apex/ config.yml plugins/ figures/ plugin.yml figures.py kbd/ plugin.yml chapters/ 01-intro.md 02-deep-dive.md</code></pre></div></div> <p>Running:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight fixed"><code><span class="nb">cd </span>my-book apex chapters/01-intro.md <span class="nt">--plugins</span></code></pre></div></div> <p>will:</p> <ul> <li>Load config from: <ul> <li>global <code class="language-plaintext highlighter-rouge">config.yml</code> (if any),</li> <li>then <code class="language-plaintext highlighter-rouge">./.apex/config.yml</code>,</li> <li>then any <code class="language-plaintext highlighter-rouge">--meta-file</code> you pass,</li> </ul> </li> <li>Run plugins from: <ul> <li><code class="language-plaintext highlighter-rouge">./.apex/plugins</code> first,</li> <li>then fall back to global plugins,</li> </ul> </li> <li>Apply per-document metadata and CLI overrides on top.</li> </ul> <p>You get per-repo behavior <strong>without</strong> having to constantly remember the right <code class="language-plaintext highlighter-rouge">--meta-file</code> or a long list of flags.</p> <h3 id="a-quick-note-on-insert">A quick note on <code class="language-plaintext highlighter-rouge">++insert++</code></h3> <p>While we were in here, another small but handy syntax has been added: <code class="language-plaintext highlighter-rouge">++insert++</code>.</p> <p><code class="language-plaintext highlighter-rouge">++insert++</code> gives you a lightweight way to add an <code class="language-plaintext highlighter-rouge">&lt;ins&gt;text&lt;/ins&gt;</code> tag to your document. It&rsquo;s just a little shorter and easier than typing out the tags manually. I try not to add too much esoteric markup to the syntax, but I&rsquo;ve seen <code class="language-plaintext highlighter-rouge">++</code> a couple of places and thought it a worthwhil addition.</p> <h3 id="wrapping-up">Wrapping up</h3> <p>To recap:</p> <ul> <li>Plugins can now live in <code class="language-plaintext highlighter-rouge">.apex/plugins</code> at the project level, and they override global plugins by id.</li> <li><code class="language-plaintext highlighter-rouge">--list-plugins</code> shows the <strong>actual</strong> set of plugins Apex will run for your current project, including overrides.</li> <li>Config can now live in <code class="language-plaintext highlighter-rouge">.apex/config.yml</code>, layered on top of your global <code class="language-plaintext highlighter-rouge">config.yml</code> and below any explicit <code class="language-plaintext highlighter-rouge">--meta-file</code>, document metadata, and flags.</li> <li><code class="language-plaintext highlighter-rouge">++insertion++</code> gives you <code class="language-plaintext highlighter-rouge">&lt;ins&gt;</code> tags.</li> </ul> <p>If you&rsquo;ve been juggling different shell aliases or wrapper scripts for each project, you can probably simplify a lot of that now by letting Apex&rsquo;s own project-aware behavior do the heavy lifting.</p> <p>Like or share this post <a class="twitter" target="_blank" rel="nofollow" href="https://twitter.com/intent/tweet?original_referer=https%3A%2F%2Fbrettterpstra.com%2F2026%2F01%2F28%2Fproject-level-plugins-and-config-for-apex%2F&text=Project-level+plugins+and+config+for+Apex&url=https%3A%2F%2Fbrettterpstra.com%2F2026%2F01%2F28%2Fproject-level-plugins-and-config-for-apex%2F&via=ttscoff" title="Tweet this post">Twitter</a>.</p> <hr style="margin:40px 0"> <p>BrettTerpstra.com is supported by readers like you. <a href="https://brettterpstra.com/support/">Click here if you'd like to help out.</a></p> <p>Find Brett on <a rel="me" href="https://hachyderm.io/@ttscoff">Mastodon</a>, <a href="https://bsky.app/profile/ttscoff.bsky.social">Bluesky</a>, <a href="https://github.com/ttscoff">GitHub</a>, and <a href="https://brettterpstra.com/elsewhere">everywhere else</a>.</p><img src="https://brett.trpstra.net/link/535/17265388.gif" height="1" width="1"/>
28.01.2026 18:14 👍 0 🔁 0 💬 0 📌 0
Preview
Dy.lan: turn your local network into a workflow engine <p>Have you ever wished you could turn your local network into a smart routing system? Instead of remembering IP addresses and ports, what if you could use simple, memorable URLs that trigger workflows, redirect to services, or even search your notes?</p> <p>That&rsquo;s exactly what <a href="https://github.com/rhsev/dy.lan">dy.lan</a> does. It&rsquo;s a self-hosted URL router with a plugin architecture that transforms your local network into a powerful automation platform. Built by Ralf Hülsmann, a Ruby rookie from Sevelen, Germany, dy.lan (pronounced &ldquo;Dylan&rdquo;) is a lightweight HTTP router designed specifically for local networks.</p> <h2 id="the-basic-concept">The Basic Concept</h2> <p>At its core, dy.lan acts as a central entry point that translates URLs into actions. Instead of remembering <code class="language-plaintext highlighter-rouge">192.168.1.73:8384</code> for Syncthing, you can access <code class="language-plaintext highlighter-rouge">http://sync.lan</code>. Instead of writing complex scripts, you can use <code class="language-plaintext highlighter-rouge">http://dy.lan/n/meeting</code> to search your Apple Notes.</p> <p>The project solves several common problems:</p> <ul> <li><strong>Infrastructure abstraction</strong>: When a service moves to a new IP or port, you update one config line instead of hunting down bookmarks and scripts</li> <li><strong>Workflow shortcuts</strong>: Turn URLs into actions with pattern-based routing</li> <li><strong>Clean local services</strong>: Route HTTP traffic without the complexity of full reverse proxies like Traefik or nginx for simple use cases</li> <li><strong>Extensibility</strong>: YAML configs for simple redirects, Ruby plugins for custom logic</li> </ul> <h2 id="plugin-architecture-and-extensibility">Plugin Architecture and Extensibility</h2> <p>What makes dy.lan powerful is its plugin system. The architecture is built around numbered plugins (00-, 10-, 20-&hellip;) that follow a first-match-wins priority system. Each plugin can:</p> <ul> <li>Match URLs using regex patterns with capture groups</li> <li>Filter by domain/host</li> <li>Execute custom Ruby logic</li> <li>Handle timeouts (default 500ms, configurable per plugin)</li> <li>Auto-disable after 5 errors (circuit breaker pattern)</li> </ul> <p>Plugins are hot-reloadable for YAML configs (after domain-match), and the system is resilient &mdash; syntax errors and loops won&rsquo;t crash the server.</p> <p>The project includes 8 example plugins covering everything from simple redirects to API integrations, monitoring dashboards, and cron jobs. You can extend it with any feature you can implement in Ruby.</p> <h2 id="real-world-examples">Real-World Examples</h2> <p>Here are some practical ways dy.lan can be used:</p> <p><strong>Google Search Shortcut</strong></p> <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight fixed"><code><span class="c1"># config/redirects.yaml</span> <span class="na">redirects</span><span class="pi">:</span> <span class="pi">-</span> <span class="na">pattern</span><span class="pi">:</span> <span class="s1">'</span><span class="s">^/g/(.+)$'</span> <span class="na">target</span><span class="pi">:</span> <span class="s1">'</span><span class="s">https://google.com/search?q=${1}'</span></code></pre></div></div> <p>Access <code class="language-plaintext highlighter-rouge">http://dy.lan/g/ruby</code> and it redirects to a Google search for &ldquo;ruby&rdquo;. Simple, memorable, and no coding required.</p> <p><strong>DEVONthink Search</strong> For more complex workflows, you can use a Ruby plugin:</p> <div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight fixed"><code><span class="k">class</span> <span class="nc">DevonthinkPlugin</span> <span class="o">&lt;</span> <span class="no">Dylan</span><span class="o">::</span><span class="no">Plugin</span> <span class="n">pattern</span> <span class="sr">%r{^/(</span><span class="se">\d</span><span class="sr">{8})}</span> <span class="k">def</span> <span class="nf">call</span><span class="p">(</span><span class="n">host</span><span class="p">,</span> <span class="n">path</span><span class="p">,</span> <span class="n">request</span><span class="p">)</span> <span class="n">alias_id</span> <span class="o">=</span> <span class="n">path</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="n">pattern</span><span class="p">)[</span><span class="mi">1</span><span class="p">]</span> <span class="no">Dylan</span><span class="o">::</span><span class="no">Response</span><span class="p">.</span><span class="nf">redirect</span><span class="p">(</span><span class="s2">"x-devonthink-item://</span><span class="si">#{</span><span class="n">alias_id</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span> <span class="k">end</span> <span class="k">end</span></code></pre></div></div> <p>Now <code class="language-plaintext highlighter-rouge">http://dy.lan/12345678</code> opens the document with that alias in DEVONthink.</p> <p><strong>Apple Notes Search</strong></p> <div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight fixed"><code><span class="k">class</span> <span class="nc">NotesPlugin</span> <span class="o">&lt;</span> <span class="no">Dylan</span><span class="o">::</span><span class="no">Plugin</span> <span class="n">pattern</span> <span class="sr">%r{^/n/(.+)}</span> <span class="k">def</span> <span class="nf">call</span><span class="p">(</span><span class="n">host</span><span class="p">,</span> <span class="n">path</span><span class="p">,</span> <span class="n">request</span><span class="p">)</span> <span class="n">query</span> <span class="o">=</span> <span class="n">path</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="n">pattern</span><span class="p">)[</span><span class="mi">1</span><span class="p">]</span> <span class="no">Dylan</span><span class="o">::</span><span class="no">Response</span><span class="p">.</span><span class="nf">redirect</span><span class="p">(</span><span class="s2">"shortcuts://run-shortcut?name=search_notes&amp;input=</span><span class="si">#{</span><span class="n">query</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span> <span class="k">end</span> <span class="k">end</span></code></pre></div></div> <p>Access <code class="language-plaintext highlighter-rouge">http://dy.lan/n/example</code> to search your Apple Notes for &ldquo;example&rdquo; via Shortcuts.</p> <h2 id="deployment-options">Deployment Options</h2> <p>One of the great things about dy.lan is its flexibility. It can run on your Mac for local development or on a Synology NAS for 24/7 operation.</p> <p><strong>On Your Mac:</strong></p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight fixed"><code>git clone https://github.com/rhsev/dy.lan <span class="nb">cd </span>dy.lan docker-compose <span class="nt">-f</span> docker-compose.mac.yml up <span class="nt">-d</span></code></pre></div></div> <p><strong>On Synology:</strong> The project includes <code class="language-plaintext highlighter-rouge">docker-compose.synology.yml</code> for easy deployment on NAS devices. With macvlan networking, you can give dy.lan its own dedicated IP address, avoiding conflicts with other reverse proxies.</p> <p>The performance is impressive: 6,000 requests per second on a Mac mini M4, and 2,500 requests per second on a Synology DS224+ (compared to ~200 req/s for YOURLS on similar hardware). All while using just 20-30 MB of RAM.</p> <h2 id="technical-highlights">Technical Highlights</h2> <p>Built with Ruby 4.0&rsquo;s async/fiber-based concurrency, dy.lan uses non-blocking I/O so slow plugins don&rsquo;t block fast ones. It&rsquo;s a pure Ruby implementation with no Rails, no middleware, and no database. Configuration is done through YAML files and Ruby plugins, with a browser-based dashboard for stats and container management.</p> <p>The system includes modern Ruby syntax (using <code class="language-plaintext highlighter-rouge">it</code> parameter in core code while keeping plugins explicit), configurable timeouts, circuit breakers, and domain filtering per plugin. It&rsquo;s designed to be simple, transparent, and easy to extend.</p> <h2 id="getting-started">Getting Started</h2> <p>If you want to try dy.lan yourself, check out the <a href="https://github.com/rhsev/dy.lan">GitHub repository</a>. The project is shared as-is, built to solve specific friction in personal automation workflows. Ralf has been developing and sharing this with me for a while, and I&rsquo;m glad he decided to make it public so others can benefit.</p> <p>Whether you need simple URL redirects, workflow automation, or a lightweight reverse proxy for local services, dy.lan provides a clean and extensible solution.</p> <p>Like or share this post <a class="twitter" target="_blank" rel="nofollow" href="https://twitter.com/intent/tweet?original_referer=https%3A%2F%2Fbrettterpstra.com%2F2026%2F01%2F27%2Fa-url-router-that-turns-your-local-network-into-a-workflow-engine%2F&text=Dy.lan%3A+turn+your+local+network+into+a+workflow+engine&url=https%3A%2F%2Fbrettterpstra.com%2F2026%2F01%2F27%2Fa-url-router-that-turns-your-local-network-into-a-workflow-engine%2F&via=ttscoff" title="Tweet this post">Twitter</a>.</p> <hr style="margin:40px 0"> <p>BrettTerpstra.com is supported by readers like you. <a href="https://brettterpstra.com/support/">Click here if you'd like to help out.</a></p> <p>Find Brett on <a rel="me" href="https://hachyderm.io/@ttscoff">Mastodon</a>, <a href="https://bsky.app/profile/ttscoff.bsky.social">Bluesky</a>, <a href="https://github.com/ttscoff">GitHub</a>, and <a href="https://brettterpstra.com/elsewhere">everywhere else</a>.</p><img src="https://brett.trpstra.net/link/535/17264591.gif" height="1" width="1"/>
27.01.2026 14:00 👍 0 🔁 0 💬 0 📌 0
Preview
Tween: Fish function for outputting ranges of text I’ve been developing a Fish function called `tween`. It’s a simple, flexible utility for extracting ranges of lines from files or STDIN input, and it’s flexible enough to handle just about any scenario you can throw at it, from numeric ranges, array style position/length ranges, or even string matching with regex capabilities. You can find the source code in my Fish functions repository, specifically at tween.fish. ## What it does `tween` displays lines between a start and end point. The start and end can be line numbers, string matches, or regex patterns. It supports multiple ranges, relative offsets, and even works with piped input. ## Basic usage The simplest form is specifying line numbers: tween file.txt 10 20 This displays lines 10 through 20 (inclusive). You can also use a dashed range format: tween file.txt 10-20 And here’s the nice part: arguments can be in any order. Both of these work the same way: tween file.txt 10 20 tween 10 20 file.txt If you only specify a single line number, `tween` will display from that line to the end of the file: tween file.txt 50 This shows lines 50 through the end of the file. ## Multiple ranges Need to extract several sections? Just separate them with commas: tween file.txt 10-20,30-40 tween file.txt 10 20, 30 40 Both formats work, so use whichever feels more natural. ## Relative offsets Sometimes you know where you want to start but need to go a certain number of lines forward. Use `+N` for relative offsets: tween file.txt 10 +20 This displays lines 10 through 30 (10 plus 20 lines). You can also count from the end using `-N`: tween file.txt 50 -10 This shows lines 50 to 10 lines from the end of the file. There’s a special case: `-1` means the end of the file (the last line): tween file.txt 50 -1 This displays lines 50 through the end of the file. Other negative numbers like `-2`, `-3`, etc. still mean “N lines from the end” (second-to-last, third-to-last, etc.). ## String matching Instead of line numbers, you can match on strings. This is super useful when you know the content but not the exact line: tween file.txt 'START' +20 This finds the line containing “START” and displays it plus the next 20 lines. You can also use string matching for both start and end: tween file.txt 50-'END' This shows lines 50 through the line containing “END”. ## Regex patterns For more complex matching, use regex patterns wrapped in slashes: tween file.txt /START/ +20 This matches the regex pattern “START” and displays that line plus 20 more. You can also use the `-r` or `--regex` flag to treat all string arguments as regex: tween -r file.txt 'foo' 'bar' Both patterns are treated as regex when using the `-r` flag. ## Exclusive mode Sometimes you want the lines _between_ two markers but not the markers themselves. Use `-e` or `--exclusive`: tween -e file.txt 'BEGIN' 'END' This displays all lines between (but not including) the lines containing “BEGIN” and “END”. ## Syntax highlighting with bat If you have `bat` installed, you can use it for syntax highlighting with the `-b` or `--bat` flag: tween -b file.txt 10-20 This displays the range with syntax highlighting, which is especially nice when viewing code. ## Piped input `tween` works with piped input too. Just use `-` as the file argument: cat file.txt | tween 10-20,30-40 - This is handy when you’re already working with a pipeline. ## Options summary * `-e, --exclusive` - Exclude the start and end lines from output * `-b, --bat` - Use bat instead of sed for syntax highlighting * `-r, --regex` - Treat all string arguments as regular expressions * `-h, --help` - Show help message The function is flexible enough to handle most text extraction tasks, and the ability to mix line numbers, strings, and regex makes it incredibly versatile. Give it a try and see how it fits into your workflow! Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
22.01.2026 16:51 👍 0 🔁 0 💬 0 📌 0
Preview
Markdown Fixup plugin for Apex My two biggest projects over the last week have been Markdown Fixup and Apex. It seemed worthwhile to integrate the two in some useful way. > A quick update on Markdown Fixup: I fixed the regex replacement engine to handle multi-line replacements. This is kind of a big deal if you’re trying to convert things like BBCode to Markdown as part of the pipeline. I’ve added a new plugin that integrates `md-fixup` directly into Apex’s processing pipeline. If you haven’t been tracking md-fixup, it’s an opinionated markdown linter and fixer that can normalize spacing, fix emphasis markers, and apply custom regex replacements to your markdown files. And if you haven’t been following Apex, it’s my “universal markdown processor” project that handles all kinds of Markdown extensions in one place. ## Why a plugin? You could always just pipe md-fixup into apex: md-fixup file.md | apex --mode kramdown That works fine for simple cases, but if you want to run md-fixup as part of a pipeline with other Apex plugins, the plugin version is the way to go. Plugins run in a deterministic order, and you can enable or disable them as needed without changing your workflow. ## When it runs The md-fixup plugin runs in the `pre_parse` phase, which means it processes your raw markdown text before Apex parses it. This is important for a couple of reasons: * **Compatibility** : md-fixup can normalize your markdown to ensure it’s compatible with Apex’s formatting expectations * **Regex replacements** : Most importantly, if you’re using custom regex replacements in your `replacements.yml` file, those run before Apex processes the text. This means you can transform markdown syntax itself, not just the final HTML output ## Installation The plugin is available in the Apex plugin directory. You can see it listed with: apex --list-plugins Install it with: apex --install-plugin md-fixup The plugin assumes you already have the md-fixup binary installed and available in your PATH. (And it uses the Rust version, which is what `brew install md-fixup` will install.) ## Configuring replacements After installation, the plugin creates a support directory at `~/.config/apex/support/md-fixup/` with a template `replacements.yml` file. Edit this file to add your custom regex replacements. The file uses YAML format with a `replacements:` array. Each replacement has: * `name`: A descriptive name for the replacement * `pattern`: A regex pattern to match * `replacement`: The replacement text (can use regex groups like `$1`, `$2`) * `timing`: When to apply (`before` or `after` other processing) * `in_code_blocks`: Whether to apply inside code blocks (`true`/`false`) * `in_frontmatter`: Whether to apply in frontmatter (`true`/`false`) Here’s an example: replacements: - name: "normalize-http" pattern: "http://" replacement: "https://" timing: after in_code_blocks: false in_frontmatter: false - name: "fix-double-spaces" pattern: " +" replacement: " " timing: after in_code_blocks: false in_frontmatter: false ## Running with plugins To use the plugins on a file, you can enable it globally, or run them for a specific conversion: apex --plugins file.md The plugin runs automatically if you’ve enabled plugins globally. The plugin is hosted at https://github.com/ApexMarkdown/apex-plugin-md-fixup if you want to check out the code or contribute improvements. And if you haven’t, check out Apex. Quick side note: it’s been pointed out that md-fixup makes a great Marked preprocessor. Marked 3 adds enough search and replace capabilities that it might not be necessary, but for Marked 2, or if you prefer to edit all your replacements in a dedicated YAML file, you can just set `md-fixup` as your Custom Proprocessor. Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
19.01.2026 18:13 👍 0 🔁 0 💬 0 📌 0
Preview
Markdown to Sendy with Image Stacks and File Uploads I’ve updated my Markdown to Sendy script with the ability to use “sliced” images with separate links, the ability to upload assets to a CDN automatically, and a “test email” mode that will actually send a test email to you without going through Sendy. If you haven’t heard of mdtosendy before, it’s a Ruby script that converts Markdown files into email-ready HTML with automatic inline styling for maximum email client compatibility. While it can generate emails for any newsletter or email platform, it integrates best with Sendy, automatically creating and scheduling campaigns. You write your emails in Markdown, maintain your styles in CSS files, and mdtosendy handles all the messy email HTML details. And it can handle multiple templates, so you can have different styles for the same newsletter, and/or configure multiple instances with different styles/settings. The latest version adds some powerful new features that make it even more useful for email workflows. ### Test Email Sending The HTML preview mode is useful, but nothing beats being able to actually view the email in an email app so you can see exactly how it will render. Now you can send test emails directly without going through Sendy. The new `--test-send` option does exactly that: mdtosendy --test-send your-email@example.com your-email.md This sends a test email directly via SMTP, bypassing Sendy entirely. Perfect for quick previews or testing your email design before creating a campaign. You’ll need to configure SMTP settings in your `config.yml`: smtp: host: "smtp.gmail.com" port: 587 domain: "gmail.com" user: "me@example.com" password: "your-app-password-here" auth: "plain" starttls: true For Gmail, you’ll need to generate an App Password (not your regular password) at https://myaccount.google.com/apppasswords. Most other SMTP providers follow a similar pattern. ### CDN Image Upload with Overwrite Control The CDN image upload feature is a significant enhancement. You can add a `cdn` section to config (which can be inherited from the main config, or set per template), and when `mdtosendy` detects a local file path, it will be uploaded to the CDN and the url in the output HTML will be updated accordingly. CDN uploads work with S3, SCP, or SFTP. See the example config in the repo for details. You can configure how `mdtosendy` handles existing files on your CDN with the `overwrite` option: cdn: url: "https://cdn.example.com" type: "s3" username: "your-access-key-id" password: "your-secret-access-key" path: "your-bucket-name" overwrite: ask # Options: true, false, or ask/prompt By default, mdtosendy uses original filenames (no timestamps) and prompts you when a file already exists. The prompt defaults to “Yes”, so you can just press Enter or type `y` to overwrite, or `n` to skip. If you choose not to overwrite, a timestamp will be added to the filename to avoid conflicts. The `overwrite` setting supports three modes: * `true` - Always overwrite without prompting * `false` - Never overwrite, automatically add timestamps when files exist * `ask` or `prompt` - Prompt for confirmation (default behavior) This works with all three CDN types: S3, SCP, and SFTP. The file existence checking is smart enough to detect existing files before uploading. ### Image Stack Tag The new `{% stack %}` tag is perfect for creating multi-part images that need to be displayed as a seamless vertical stack. This is especially useful for sliced images or creating visual sections in your emails. You can use it with markdown image syntax: {% stack %} ! ! ! {% endstack %} Or with YAML for more structured definitions: {% stack type="yaml" %} images: - path: /images/image1.png url: https://example.com/link1 - path: /images/image2.png url: https://example.com/link2 - path: /images/image3.png {% endstack %} The stack tag creates a vertical stack of images with zero spacing between them, all full width of the content area. Each image can optionally have a link. Local images in stacks are automatically uploaded to your CDN if you have it configured, and the tag uses a table-based layout for maximum email client compatibility. ### Demo Please! Here’s an email generated from the `demo.md` file in the repository, using my own `marked` template, which uploads to my cdn.marked2app.com bucket on S3. The demo shows a single image uploaded to the CDN from a local file path, as well as 3 separate images combined into one stack, with 3 separate links. Demo email ### Getting Started If you’re new to `mdtosendy`, installation is a one-liner: curl -s https://github.com/ttscoff/mdtosendy/raw/main/bootstrap.sh | bash For more details on all the features, configuration options, and examples, check out the mdtosendy repository on GitHub. Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
17.01.2026 12:22 👍 0 🔁 0 💬 0 📌 0
Preview
Marked features for Apex When Apex reaches 1.0, I’m planning to include it in Marked 3. I realized that Marked has a lot of preprocessing features that were previously handled in Objective-C that would make sense to have in the core processor for both speed and accessibility from the command line. So I’ve added a bunch of new flags and C API definitions to Apex that bring some of Marked’s capabilities directly into the processor. These are all available via command-line flags, configuration options, and the C API. ## Hashtags The `--hashtags` flag converts `#tags` in your Markdown into span-wrapped hashtags. By default, it uses the `mkhashtag` class, but you can use `--style-hashtags` to use `mkstyledtag` instead. apex document.md --hashtags apex document.md --hashtags --style-hashtags This is smart enough to skip hashtags inside code blocks and HTML attributes, so you won’t get false matches in things like `href="https://brettterpstra.com#anchor"` or code examples. Hashtags are disabled by default because they would conflict with headers if you’re not in the habit of putting a space after the `#` in an ATX header (e.g. `#Header 1`). This option is only for people who want to convert things like Bear notes with tag formatting. They can be enabled with `--hashtags` on the command line, by including `hashtags: true` in a config file, or by using the `enable_hashtags` boolean when using the C API. ## Random Footnote IDs When you’re combining multiple documents, footnote ID collisions can be a problem. The `--random-footnote-ids` flag generates hash-based footnote IDs using an 8-character hex prefix from the document content. Instead of `fn-1` and `fnref-1`, you’ll get `fn-a7b3c9d2-1` and `fnref-a7b3c9d2-1`. Different documents get different hash prefixes, so you can safely combine them without conflicts. ## Widon’t for Headings The `--widont` flag prevents short widows in headings by inserting non-breaking spaces between trailing words. It works backwards from the end of the heading, combining words until the trailing portion exceeds 10 characters. So a heading like “introduction to the topic” becomes `introduction to the topic` — ensuring that if the heading wraps, the trailing portion won’t be a short, lonely word on its own line. Widon’t is disabled by default in all modes, as it might create potentially unexpected results if the user isn’t aware of it. It can be explicitly enabld with `--widont`, `widont: true` in config, or with the `enable_widont` boolean in the C API. ## Code is Poetry Code blocks without a programming language specified can be treated as poetry with the `--code-is-poetry` flag. This adds a `poetry` class to code blocks that don’t have a language specified, and automatically enables `--highlight-language-only` so only code blocks with languages get syntax highlighting. Works for both fenced code blocks and indented code blocks. Again, this is disabled by default as it has very specific use cases. ## Proofreader Mode The `--proofreader` flag converts `==highlight==` and `~~delete~~` syntax into CriticMarkup highlight and deletion. It automatically enables CriticMarkup processing, so you can use this simpler syntax and still get the full CriticMarkup rendering. ## Markdown in HTML Toggle Marked has always processed markdown inside HTML blocks with `markdown` attributes. Now you can control this behavior with `--markdown-in-html` and `--no-markdown-in-html`. It’s enabled by default in unified mode, but you can toggle it for other modes or when you need stricter behavior. ## Page Breaks Two new flags for page break handling: * `--hr-page-break` replaces `<hr>` elements with Marked-style page break divs * `--page-break-before-footnotes` inserts a page break before the footnotes section Both use Marked’s standard page break format with proper attributes for styling and identification. ## Title from H1 When using `--standalone`, the `--title-from-h1` flag extracts the text from the first H1 heading and uses it as the document title if no title is specified via `--title` or metadata. The H1 stays in the document body, but now it’s also in the `<title>` tag. ## All Available Everywhere All of these features are available through: * Command-line flags (as shown above) * Configuration files (`config.yml`, `--meta-file`) * Document metadata (YAML front matter, MultiMarkdown metadata) * The C API (via the `apex_options` struct) This means you can set defaults in your config file, override per-document in metadata, or use command-line flags for one-off processing. The flexibility is there. ## Why Build These Into Apex? These features were originally preprocessing steps in Marked’s Objective-C code. Moving them into Apex provides: 1. **Speed** — C-based processing is faster than Objective-C string manipulation 2. **Accessibility** — Available from the command line without needing Marked 3. **Consistency** — Same behavior whether called from Marked or directly 4. **Extensibility** — Other tools can use these features via the C API As Apex approaches 1.0, these features will make the integration with Marked a little easier while also making some of Marked’s capabilities available to anyone using Apex directly. I know none of these recent changes are killer features for most people, so this is just serving as documentation of the development process. As always, as Apex develops, I’m very interested in what features you’d like to see. The goal is to have one universal processor that, at least for HTML output1, can do everything that other Markdown flavors can do. If you have ideas or requests, or want to contribute to the development, please join me on GitHub! 1. I’m not going to try to replicate all of Pandoc’s powerful conversion features, as you can just pipe Apex HTML output into Pandoc for easy conversion to PDF, DOCX, etc. I’m just focusing on making as many extensions for HTML output as possible work. ↩ Like or share this post on Mastodon, Bluesky, or Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
16.01.2026 14:00 👍 0 🔁 0 💬 0 📌 0
Preview
Git-based changelogs for Rust projects and more I’ve been using my changelog script for years to generate release notes from git commit messages. It’s saved me countless hours and helped me maintain complete, informative changelogs across all my projects. I wrote `changelog` in 2017 and mentioned it back in 2024. I’ve used it for every project I’ve worked on since then, and I’ve been improving it and making it work with more and more project types over the years. ### Rust project support The latest update adds support for Rust projects, which I’m just dipping my toes into. The script now automatically detects Rust projects in two ways: 1. **Cargo.toml detection** : If your project has a `Cargo.toml` file, it reads the `name` and `version` fields directly from it. 2. **Rust source file scanning** : For projects without a `Cargo.toml` (or as a fallback), it scans `.rs` files looking for version constants like `const VERSION: &str = "1.0.0"` and command names from `Command::new("appname")`. This means you can use the changelog script with any Rust project just by running `changelog` or `changelog -u` to update your `CHANGELOG.md` file, and it will automatically pull the version from your `Cargo.toml` or source files. ### Other improvements Along with Rust support, I’ve made a few other improvements: * **VERSION constant** : The script now includes its own `VERSION` constant that stays in sync with the `VERSION` file, making it easier to track the script’s version. * **Code refactoring** : I’ve extracted the version detection, git log parsing, and formatting logic into separate modules (`VersionDetector`, `GitLogParser`, and `ChangelogFormatter`) to make the codebase more maintainable. * **Better version detection** : The script now has a more robust fallback chain for detecting versions, with Rust projects taking priority when detected, followed by Ruby gems, Xcode projects, and plain `VERSION` files. * **`--since-version` flag**: You can now generate changelogs starting from a specific version tag using `--since-version` (or `--sv` for short). This is useful when you want to see changes since a particular release, and it supports partial version matching. For example, `changelog --since-version 1.0` will find the most recent tag starting with “1.0” and generate a changelog from that point. * **`--select` flag**: If you prefer an interactive approach, use `--select` (or `-s`) to pop up an `fzf` menu of all your git tags. This lets you visually choose which tag to use as the starting point for your changelog generation. Perfect for when you can’t remember the exact version number but know roughly when you want to start from. * **`--only` flag**: Filter changelog output to show only specific change types. Use it like `changelog --only new,fixed` to see only new features and bug fixes, or `changelog --only changed` to see only breaking changes and modifications. * **`--split` flag**: When used with `--select` or `--since-version`, this splits the changelog output by version tag, showing what changed in each release separately. This is great for generating comprehensive release notes that span multiple versions. You can control the order with `--order asc` (oldest first) or `--order desc` (newest first, default). ### How it works The script works exactly the same way it always has — you format your commit messages with prefixes like `NEW:` or `FIXED:`, or tags like `@new`, `@fixed`, `@changed`, etc., and it generates a changelog from commits since your last git tag. The only difference now is that it works seamlessly with Rust projects without any additional configuration. The script is still designed to fit my personal workflow (including some nvUltra, Marked, and Bunch-specific formatting), but it’s open for anyone to hack on and adapt to their needs. All of my project-specific paths are gated by exact directory structures and won’t affect operation by anyone else. If you’re working with a Rust, Xcode, Ruby, or any script project and want to automate your changelog generation, give it a try. It makes it so easy to build a changelog and release notes as you develop, and have them ready to go when you hit release time. Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
15.01.2026 14:00 👍 0 🔁 0 💬 0 📌 0
Preview
Regex Replacements for Markdown Fixup BBEdit has a cool feature called Text Factories for automating repetitive text transformations. When Younghart mentioned it on the forum, it got me thinking. While I can’t replicate the full power of Text Factories (which can chain multiple transformations, with all of BBEdit’s power), I could at least add a flexible regex search-and-replace system to md-fixup. So that’s what I built: a YAML-driven regex replacement engine that lets you define custom patterns that run as part of an `md-fixup` pass. It’s not a replacement for Text Factories — it only does regex search and replace — but it offers a way to extend `md-fixup` with your own transformations. ## How It Works Replacements are defined in a YAML file and can be scoped to run before or after Markdown Fixup’s built-in rules. Each replacement can optionally run inside code blocks or YAML frontmatter, giving you fine-grained control over where transformations happen. The replacement file lives in one of these locations (checked in order): * `.md-fixup-replacements` in your current directory * The path specified in your config file’s `replacements_file:` key * `~/.config/md-fixup/replacements.yml` (or `$XDG_CONFIG_HOME/md-fixup/replacements.yml`) ## Setting Up Replacements Here’s a simple example that fixes double spaces and normalizes HTTP links: replacements: - name: "fix-double-spaces" pattern: " +" replacement: " " timing: after in_code_blocks: false in_frontmatter: false - name: "normalize-http" pattern: "http://" replacement: "https://" timing: after Each replacement has a few key properties: * **name** : A human-readable identifier (useful for debugging) * **pattern** : A Rust `regex` pattern (supports capture groups) * **replacement** : The replacement string (use `$1`, `$2`, etc. for capture groups) * **timing** : When to run—`before` or `after` the built-in rules * **in_code_blocks** : If `true`, the pattern runs inside fenced code blocks * **in_frontmatter** : If `true`, the pattern runs inside YAML frontmatter ## A More Complex Example Here’s one that swaps version numbers using capture groups: replacements: - name: "swap-version" pattern: "(\\d+)\\.(\\d+)" replacement: "$2.$1" timing: before This would turn “Version 1.2 is released” into “Version 2.1 is released”. The pattern captures two groups of digits separated by a dot, then swaps them in the replacement. Stupid example, of course (why would you ever do that?), but hopefully you get the idea. ## Controlling Replacements You can enable or disable replacements via your config file: width: 80 replacements: true replacements_file: ~/my-replacements.yml rules: skip: - wrap Or use command-line flags: # Force enable md-fixup --replacements file.md # Force disable md-fixup --no-replacements file.md # Use a specific file md-fixup --replacement-file ./custom.yml file.md This regex replacement system is intentionally limited, and as mentioned, is no replacement for Text Factories. It only does regex search and replace, but it runs automatically as part of md-fixup’s fixup pass, which means you can integrate custom transformations into your existing workflow without switching tools. If you need the full power of Text Factories, by all means use BBEdit. It’s an amazing text editor. But if you just need a few regex replacements to run alongside Markdown Fixup’s built-in rules, this might be exactly what you’re looking for. Check out the latest version of Markdown Fixup on GitHub. Find installation and usage instructions there! Like or share this post on Mastodon, Bluesky, or Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
14.01.2026 14:00 👍 0 🔁 0 💬 0 📌 0
Preview
Built-in Syntax Highlighting for Apex I think a lot of people using Apex are going to want syntax highlighting of code blocks. Including a script like Highlight.js in your HTML output is _fine_ , but I wanted Apex to be able to directly output HTML with the necessary spans and tables for highlighting. So, introducing the `--code-highlight` flag. ### How It Works Rather than bundling a syntax highlighting engine (which would bloat the binary and require constant updates for new languages), I decided to leverage external tools that you probably already have installed. The new `--code-highlight` flag accepts either `pygments` or `skylighting` as arguments: # Using Pygments (Python-based) apex --code-highlight pygments input.md # Using Skylighting (Haskell-based, used by Pandoc) apex --code-highlight skylighting input.md # Short forms work too apex --code-highlight p input.md # pygments apex --code-highlight s input.md # skylighting When you specify a highlighter, Apex processes your Markdown normally, then makes a second pass over the HTML output. It finds all `<pre><code>` blocks, extracts the raw code content, pipes it through the external tool, and replaces the original block with the colorized HTML output. ### Language Detection Apex handles language specification in the standard way you’d expect. Just add the language identifier after the opening fence: ```python def hello(): print("Hello, world!") ``` The language is extracted from either the `class="language-XXX"` attribute on the code tag or the `lang="XXX"` attribute on the pre tag (depending on how cmark-gfm formatted it). This language is then passed to the external highlighter. If you don’t specify a language, both Pygments and Skylighting will attempt auto-detection. Pygments is particularly good at this thanks to its `-g` (guess) flag. Skylighting will try its best but may fall back to plain text for ambiguous code. ### Line Numbers Sometimes you want line numbers in your code blocks, especially for tutorials or when referencing specific lines. The new `--code-line-numbers` flag has you covered: apex --code-highlight pygments --code-line-numbers input.md This passes the appropriate options to the highlighter (Pygments gets `linenos=1`, Skylighting gets the `-n` flag). The result is nicely numbered code that’s easy to reference. ### Automatic Styling in Standalone Mode When you use `--code-highlight` with `--standalone` and don’t specify a `--css FILE` option, Apex automatically embeds GitHub-style syntax highlighting CSS in the document head. This covers all the common class names used by both Pygments and Skylighting, so your highlighted code looks great out of the box. apex --standalone --code-highlight pygments input.md > output.html The embedded CSS uses a clean, GitHub-inspired color scheme that works well with light backgrounds. Keywords are red, strings are blue, comments are gray, and so on. If you want different colors, you can always override with your own stylesheet using the `--css` flag. ### Multiple Stylesheets Speaking of stylesheets, another improvement today is that `--css` now accepts multiple files. You can either use the flag multiple times or pass a comma-separated list: # Multiple flags apex --standalone --css base.css --css syntax.css input.md # Comma-separated apex --standalone --css base.css,syntax.css,custom.css input.md All specified stylesheets are linked in the document head in the order provided. If you use `--embed-css`, all of them get embedded as inline `<style>` blocks. ### Requirements You’ll need one of the external tools installed and available in your PATH: * **Pygments** : Install with `pip install Pygments`. The `pygmentize` binary needs to be accessible. * **Skylighting** : Install with `cabal install skylighting` or get it with Homebrew (`brew install skylighting`). The `skylighting` binary needs to be accessible. If the specified tool isn’t found, Apex will print a warning and leave your code blocks unstyled (but otherwise functional). ### Other Updates A few other things landed alongside the syntax highlighting feature: * **ANSI art logo** in `--version` output, because why not? * **Test runner badge mode** (`--badge`) that outputs just the pass/fail count for CI badge generation * **Improved test output** in errors-only mode now only shows suite titles for suites with failures * **Test infrastructure refactoring** with new suite tracking helpers for cleaner, more maintainable tests I think the syntax highlighting will be a very useful feature, at least for people publishing code. Integrating with external tools means Apex handles the Markdown parsing and document structure, while battle-tested highlighters handle the colorization. Best of both worlds. Like or share this post Twitter. * * * BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out. Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.
13.01.2026 14:42 👍 0 🔁 0 💬 0 📌 0