Like jq does for JSON, jaq transforms structured data, such as XML, YAML, TOML, CSV (and also JSON).
The other morning, I needed to transform TSV into CSV for import to application that only accepts the latter.
I tried a couple different command line tools and found that while there are lots of tools that will help you convert to CSV to TSV, the ones that convert TSV to CSV are harder to find.
I eventually gave up, made some changes to get a JSON export, then used jq’s @csv filter to produce the CSV file.
In the afternoon and with a mix of happiness and irritation, I learned of jaq, a mostly-compatible jq replacement that can filter many other formats using the same jq filter language.
For example, suppose I have a CSV file with data about cities, like this truncated example:
city,cityLabel,population,country,countryLabel,loc
http://www.wikidata.org/entity/Q11725,Chongqing,32054159,http://www.wikidata.org/entity/Q148,People's Republic of China,Point(106.506944444 29.55)
http://www.wikidata.org/entity/Q1353,Delhi,26495000,http://www.wikidata.org/entity/Q668,India,Point(77.216666666 28.666666666)
http://www.wikidata.org/entity/Q665894,Greater Mexico City,21905000,http://www.wikidata.org/entity/Q96,Mexico,Point(-99.133158333 19.432519444)
http://www.wikidata.org/entity/Q683705,New York metropolitan area,19940274,http://www.wikidata.org/entity/Q30,United States,Point(-74.020277777 40.808611111)
http://www.wikidata.org/entity/Q1354,Dhaka,16800000,http://www.wikidata.org/entity/Q902,Bangladesh,Point(90.394444444 23.728888888)
With jaq, I can query it as if it were an array of arrays and turn it into JSON:
$ jaq --slurp \
'.[1:6] | [.[] | { "city": .[1], "country": .[4] }]' \
./cities.csv
[
{
"city": "Chongqing",
"country": "People's Republic of China"
},
{
"city": "Delhi",
"country": "India"
},
{
"city": "Greater Mexico City",
"country": "Mexico"
},
{
"city": "New York metropolitan area",
"country": "United States"
},
{
"city": "Dhaka",
"country": "Bangladesh"
}
]
While making this example, I was pleasantly surprised by it’s nicer-than-jq error messages:
$ jaq --slurp '.[1:6] | [.[] | { "city": .[1], "country": .[4] }' ./cities.csv
Error: expected closing bracket
╭─[<inline>]
│
1 │ .[1:6] | [.[] | { "city": .[1], "country": .[4] }
┆ ┬ ┬
┆ │ │
┆ ╰───────────────────────────────────────── unclosed delimiter [
┆ │
┆ ╰─ unexpected end of input
──╯
And the task I wished to do in the morning—convert TSV to CSV—was refreshingly straightforward:
$ jaq --from tsv --to csv '.' export.txt
The version 3 release notes call it a “Pandoc for structured data” and that feels right.
The syntaxes jq uses for extracting an object’s keys, values, and entries are confusingly dissimilar.
One of them is not like the others.
Here’s a brief example for each, given this input.json file:
{
"name": "Daniel",
"location": "Amsterdam"
}
To get the keys and values of an object as an array of key-value pairs, like JavaScript’s Object.entries(), use to_entries:
$ jq 'to_entries' input.json
[
{
"key": "name",
"value": "Daniel"
},
{
"key": "location",
"value": "Amsterdam"
}
]
To get the keys of an object as an array, like JavaScript’s Object.keys(), use keys (or keys_unsorted):
$ jq 'keys' input.json
[
"location",
"name"
]
The last one, to get the values of as an array, is an oddity.
It doesn’t have a named builtin.
Something like to_values doesn’t exist and the values builtin does something else entirely, filtering to non-null values.
To get the values of an object as an array, like JavaScript’s Object.values(), use [.[]].
In other words, use the array/object value iterator (.[]) inside an array construction ([]).
$ jq '[.[]]'
[
"Daniel",
"Amsterdam"
]
Note that the order of .[] is not the same as keys but rather like keys_unsorted.
If you want the values in the same order as keys, you must do something even more indirect, like this:
jq 'to_entries | sort_by(.key) | .[].value' input.json
Addendum: jq contributor Mattias Wadman suggests the more compact [.[keys[]]] to get keys-ordered values.
With any luck, writing this down will have cemented this fact in my head.
Spec fiction is that which was specified but never implemented.
A lot of my work is in web platform documentation, such as web-features (a structured catalog of browser capabilities) and MDN (the nearest thing to a canonical reference for HTML, CSS, JavaScript, and browser APIs).
As a consequence, I read a lot of browser specifications (specs), to find out what browsers are supposed to do.
Since specs are written iteratively and often early in the development process, each spec reflects a mix of what ought to be and what is.
Some years ago, I coined the term spec fiction to refer to things that exist in specs but do not actually exist any implementation and for which there is no evidence of an implementation in progress.
In case it’s not obvious, I meant this as a pun on the abbreviation for speculative fiction.
And I can’t help but say it a little pejoratively.
It’s (usually) too much to say that published browser specs lie, but they contain a great number of imaginary things.
There are some specs that read more as wishlists than anything else (for example, CSS’s various print media features seem to have been specified far more extensively than implemented).
But through some combination of ambition, optimism, changing priorities, and a reluctance to walk back agreed upon text, spec fiction persists.
With Git, I sometimes check out a single file, such as a shell script, from another branch.
I was not familiar with how to do this with jj (Jujutsu), so I’m writing it down and making a note of how jj’s equivalent confuses me.
These Git and Jujutsu commands are roughly equivalent:
git checkout <tree-ish> -- <pathspec>…
jj restore --from <REVSET> FILESETS…
Both commands copy one or more files from another revision into your current working copy.
They differ mostly in notation.
Git’s <tree-ish> is a commit hash, branch name, or tag and <pathspec>… is one or more paths to files.
While jj’s <REVSET> is a revset expression that resolves to one revision and FILESETS… are one or more fileset expressions.
For example, these two commands copy scripts/setup.sh from another revision into the current working copy:
git checkout abcde -- scripts/setup.sh
jj restore --from qxzy scripts/setup.sh
This brings me to a light criticism of jj and its docs.
Jujutsu has no command quite like git checkout, so I found it somewhat difficult to figure out how to do what the corresponding Git command does.
In particular, I find it difficult to recognize at a glance whether a jj command operates on one or many revisions and when to expect one or the other.
The jj docs do make a distinction between revset and revsets, where the singular refers to a revset expression that resolves to exactly one revision, but it doesn’t exactly jump off the page.
Moreover, it’s not clear to me that the jj restore --from command should operate on just one revision.
Surely it should be safe to check out a file from many revisions, so long as those revisions don’t conflict?
And jj has otherwise conditioned me to believe that conflicts aren’t to be feared, so why not accept revsets plural and restore a conflicted file, if needs must?
A software development team decides to start a tech blog.
After a burst of enthusiasm, it soon goes dormant.
Why?
Peter Hilton describes the typical fate of a software development team’s tech blog:
New tech blogs can burn lots of effort, and deliver disappointing results before eventually failing.
[…]
If you don’t solve the tech blog problem, you end up with an embarrassing abandoned blog that only has two lonely posts. And one of them describes the CMS set-up.
Peter describes three reasons why this happens to team tech blogs.
I think the third problem, relying on volunteerism for articles, is the most important.
Peter talks about one strategy for solving this, which is to compel members of the team to write blog posts.
And sure, insist that the blog represents the team by having the whole team participate.
But I’m going to suggest going a step further: hire a ghostwriter.
Asking a software developer with other duties to write unassisted is likely to take a lot of time to yield a publishable draft (or no time at all to yield slop).
And a software developer’s time isn’t cheap.
But a technical writer can save a lot of it.
A good technical writer can work collaboratively with a software developer according to their time and interest, scaling the effort to extract a draft from a brief interview or editing an enthusiastic author.
Even paying an expensive writer’s high hourly rate, you might come out ahead on the investment on time saved alone, to say nothing of quality.
And it also helps with another problem Peter notes, the false start.
In addition to actual writing skills, a professional writer can project manage the writing process.
You can avoid the false start by making publishing someone’s actual responsibility, not an also-ran to their regular job.
When I’m working with PDFs, the tools I turn to most frequently are Poppler’s pdftotext and pdftohtml and Pandoc, with support from OCRmyPDF and (rip)grep.
Poppler’s pdftotext converts PDFs to plain text, with the -layout option being especially helpful.
You can use it make PDF text grep-able, often with no fuss (see also: Better-than-Grep tools for writers and developers alike).
For example, I use it to scrape client names and invoice numbers from PDFs.
The Poppler tools get even more powerful in combination with Pandoc.
For instance, if you have a badly-formatted PDF with a defect like exceedingly long line lengths or a difficult-to-read font, then you can transform it into something more readable.
Here’s a remarkably tolerable workflow to tame a bad PDF:
- Use
pdftohtml to convert a PDF to HTML.
- Use
pandoc to convert HTML to Markdown (CommonMark).
- If really necessary, do some manual clean up on the Markdown.
- Use
pandoc to convert Markdown to EPUB or back to PDF.
If your PDF contains only image data (i.e., it was scanned without OCR), then you will still have a lot of difficulty.
But OCRmyPDF might give you a fighting chance, in combination with the tools above.
These tools are fast, work offline, and are safe to use with confidential or otherwise sensitive documents.
I’m sure there are other tools for unmangling PDF text—please tell me about them!—but these are the ones I turn to often enough that I haven’t gone looking for others.
GNU Stow has a new-to-me --dotfiles option.
Stow is a “symlink farm manager” that installs files from a directory into a target location by creating a bunch of symlinks.
Apart from taking the tedium out of creating a bunch of symlinks, it offers some safety and conveniences over ln -s, such as an option to bring existing files under Stow’s management.
According to my Git history, I’ve been using Stow for over 6 years.
I use it for a few things, such as installing per-project shell completions.
But mostly I use it to manage the dotfiles in my home directory.
One of Stow’s sensible constraints is that the files under management (a “package” in Stow’s terminology) must reflect the directory structure that Stow reconstructs with symlinks.
If I want to manage my ~/.config/fish/config.fish file with Stow, then my package must have a structure like daniels-fish-files/.config/fish/config.fish.
This is fine except for .config’s literal leading dot, which requires some extra care and attention.
For instance, a text editor might not show that directory by default and my shell’s tab completion won’t readily suggest it.
And in the case of my dotfiles Git repository, it’s easy to mix up .gitignore files.
But recently I was removing some unused configuration files and learned that as of 2024, Stow has a working --dotfiles option.
With this command line option set, files and folders with names prefixed by dot- are converted to . when creating symlinks.
So daniels-fish-files/dot-config/fish/config.fish is symlinked into my home directory as .config/fish/config.fish.
This more or less eliminates any annoyances I had with using Stow.
The jj version control system generates branch names that leave something to be desired.
Here’s my alternative.
I’ve been using jj (Jujutsu), a Git-compatible version control system recently.
I’m pretty happy with it.
Today I’m happy with its configurability.
For a number of reasons, jj expects and encourages the use of anonymous branches.
But if you push to a Git repository, then the anonymous branch needs a name (a “bookmark” in jj, a “branch” in Git).
The jj command-line interface provides a shorthand to push a given change, generating a name automatically.
For example, jj git push --change xyz pushes the revision with the ID xyz to a Git branch named push-xyz.
As a default, it makes sense to emphasize the change ID, since the jj CLI shows and uses those IDs often.
But suppose later on I’m looking at the list of branches on the repository on the GitHub website.
What actual work does push-xyz represent?
I cannot and will not remember.
I changed my configuration to fix this.
I wrote a new template alias, slugify(), and changed the git_push_bookmark template to use it:
[template-aliases]
"slugify(str)" = '''
truncate_end(
65,
str.first_line()
.replace(regex:'[^[[:alnum:]].]', '-')
.replace(regex:'-{2,}', '-')
.replace(regex:'\.{2,}', '.')
.replace(regex:'(^-+|-+$)', '')
.lower()
)
'''
[templates]
git_push_bookmark = 'slugify(description) ++ "/" ++ change_id.short()'
Now, if I run jj git push --change ozkspkuyzpwu, jj generates a short slug-like name from the change’s description (the commit message, if you’re coming from Git).
In this case, it generates add-note-about-jj-bookmark-templates/ozkspkuyzpwu.
This is more readable while retaining the link back to the revision IDs that show up in the jj CLI.
If I were to push to a repo shared with others, then I would change it put my branches into a namespace for myself:
[templates]
git_push_bookmark = '"ddbeck/" ++ slugify(description) ++ "/" ++ change_id.short()'
One thing I didn’t do is make sure that the branch names are safe for Git.
Git has some awfully complicated rules to determine whether a branch name is valid.
I assume that I’ll get an error if my template ever generates an invalid branch name, at which point I’ll have to create a bookmark manually.
I’ve written previously about using Tailscale to wake a PC remotely.
I’m now using Home Assistant to wake it in fewer steps.
To recap, I wanted to wake a PC at home from another continent, but WoL requires a physical link.
I used Tailscale to remotely access my NAS on the same physical network as the PC, then used it to send a WoL magic packet.
Since then, I started running Home Assistant on a machine at home, to control some lights and power outlets.
I recently learned that Home Assistant has a WoL integration.
I used it to add a Wake [target PC hostname] button to my Home Assistant dashboard.
Now I can wake that PC with one click, instead of fussing with the terminal.
This also helps with my remote-access use case.
Since my Home Assistant instance is available to me via Tailscale, I can use the wake button from more or less anywhere with an internet connection.
What’s more, the target is a home theater PC which cannot be woken by Bluetooth gamepads.
So I’ve embellished my living room setup to wake the PC from the couch, with a Zigbee button or my phone.
At some point, I'd like to learn more about self-hosting Headscale or using Wireguard directly.
But for now, I'm quite pleased by how well all this stuff fits together.
The fish shell set_color command writes escape sequences to standard output.
At least in fish, color text in your terminal isn’t a special trick of the shell.
It’s just bytes.
A nice thing about being a fish shell user is that I don’t think that hard about the shell and my terminal emulator.
They do unsurprising things, most of the time.
So I was caught off guard recently when combining set_color with a redirect to standard error, like this:
# You don't want this! It's bad!
function misbehaving_debug
set_color blue
echo -n "debug: " 1>&2
set_color --reset
echo $argv 1>&2
end
When I called this function, the debugging messages would sometimes be blue (or not) and other text—not printed from this function—would be blue.
It was mystifying, until I read fish-shell/fish-shell#2378.
Like another fish user, I thought set_color set some internal shell state that controlled the color of the output that ultimately reached the terminal.
But it’s not special.
It writes invisible characters to standard output.
You can inspect those characters even, using the new-to-me od command.
Running set_color blue | od -a shows the escape sequence for blue text.
Since it’s writing ordinary characters to standard output, set_color blue is roughly equivalent to echo -e "\e[34m" (though much nicer to type).
It’s now clear to me that, since I failed to redirect set_color blue in addition to echo, the color escape sequences were affecting the wrong output streams.
Equipped with this new information, my revised debug function redirects the escape sequences and the debug message:
function debug
echo (set_color blue)debug(set_color --reset): $argv 1>&2
end
I contributed fish-shell/fish-shell#12644 to improve the fish documentation on this point.
Recently I learned that the <q> element exists.
And it’s no wonder that I didn’t learn about it sooner: it’s goofy.
The <q> HTML element is the inline counterpart to <blockquote>.
In browsers’ default style sheets, curly “smart” quotes are automatically added to this element with some CSS like this:
q:before {
content: open-quote;
}
q:after {
content: close-quote;
}
This is probably the only good thing about the <q> element.
It has several surprising downsides:
-
Quote characters are difficult to predict.
Quote characters are selected by the browser, based on the user’s preferred language and the document language.
If you have multilingual text or just want to use a specific quotation mark, you’ll probably have to write some lightly complicated CSS to make it consistent.
-
Text search is confusing.
In Firefox 147, searching for a literal quotation mark finds quotation marks inserted by the browser’s default style sheet.
But searching for a literal quotation mark plus the next character in a <q> element does not find the quoted text.
-
Text selection is weirded.
In Firefox 147, selecting <q> element text does not highlight the quotation marks, but Firefox does copy the quotation marks to the clipboard.
In contrast, Chrome 144 does not copy the quotation marks to the clipboard.
Even the HTML specification seems to be a little embarrassed by <q>:
You don’t have to use it.
The use of q elements to mark up quotations is entirely optional;
using explicit quotation punctuation without q elements is just as correct.
You probably shouldn’t use the <q> element.
Last week, I got my first commit in Git.
After many years of being a Git user, I am now a contributor to Git itself.
My change documents the git fetch --jobs=0 option, which I’ve written about previously.
Here are some things I noticed while working on my contribution:
-
Contributing to Git is intimidating, even as someone accustomed to working in public on open source software.
Some of the discomfort came from unfamiliar process, such as sending patches to a mailing list.
But most of it comes from the mailing list’s exacting standards paired with a terse and not exactly welcoming communication style.
-
On the Git mailing list, it’s conventional to CC people who might be interested in your patch.
On GitHub, I might find it a little irritating to be @-mentioned by someone I don’t know.
But CCing someone you don’t know is ordinary practice on the Git mailing list.
There’s even a script, git-contacts to help do this.
It finds people who had previously authored or reviewed the code you’re modifying and formats the CC header for you.
-
I got a little help from Julia Evans, especially her writing on contributing new docs to Git and the resources she linked to.
I probably wouldn’t have thought to contribute to Git without reading about her experience first.
For example, I wouldn’t have known that GitGitGadget eases contribution for people like me who are used to GitHub’s fork-and-pull model.
Now that I’ve gotten over the initial difficulty, I’m less intimidated.
In all, it was nice to contribute to a tool that I rely on, even if with just a one-line docs patch.
Back in December, LWN.net’s Jake Edge covered Erin McKean’s talk at Open Source Summit Japan 2025.
The article covers Erin’s talk (video), which was based in part on our Open Source Software Documentation Workshop at Open Source Summit Europe earlier in the year.
The article mentions my Documentation Project Archetypes and Erin Kissane’s Docs Advisor.
It was fun to see news coverage (albeit niche) of something I’m closely connected to.
I’m glad to see these ideas circulate more widely, especially Erin’s point that “docs don’t happen by magic; somebody has to do something in a planned way to make docs.”
Ten years ago, Kate Compton wrote “So you want to build a generator….”
Nothing better prepared me for a world where text and image generation is a popular compulsion.
Circa 2016, there was a glut of procedural generation in video games and computer art.
Dr. Compton writes:
Something has gone horribly wrong.
The content looks ugly.
The content all looks the same.
The content looks like genitalia.
The content is broken.
Some of these problems are easier to solve than others.
Here are a few kinds of difficult problems you will encounter.
Compton writes about the various ways you can generate and their many failures, including, famously, the 10,000 Bowls of Oatmeal problem (emphasis in original):
I can easily generate 10,000 bowls of plain oatmeal, with each oat being in a different position and different orientation, and mathematically speaking they will all be completely unique.
But the user will likely just see a lot of oatmeal.
Perceptual uniqueness is the real metric, and it’s darn tough.
A neverending stream of oatmeal is the benign ancestor of today’s slop.
And it’s just one of several ways to fail at generating things.
We now live in a world where many complex things can be generated, which were impractical to generate until only recently.
Yet the failure to create perceptual uniqueness or “characterful artifacts” persists.
Generating more and faster is not a strategy for making meaning.
It wasn’t the case ten years ago and it is not the case today.
Pandoc for the people runs pandoc in your web browser.
You can use it to convert documents without a command line.
Pandoc is my go-to command-line tool for doing lots of things with text documents (previously).
But for one-off conversions with formats I’m less accustomed to, this web interface is a convenient way to explore format-specific settings.
A cool thing about this is that’s the pandoc I know and love, compiled to WebAssembly.
The conversion happens in your browser and documents are not sent over the network.
(Yes, I checked).
It even runs Lua filters to modify documents during conversion.
Tall Man lettering seeks to avoid medication errors by using casing to make look-alike names look less alike.
For example, bupropion can be written as buPROPion to avoid confusion with buspirone (busPIRone).
But Wikipedia links to an editorial in BMJ Quality & Safety (doi: 10.1136/bmjqs-2015-004929) that says varying case may not reduce errors:
However, apart from limited evidence of effectiveness in laboratory settings, no evidence shows that this technique prevents drug name confusion errors in clinical practice.
If you work with many Git remotes or submodules, then commands like git fetch --all or git fetch --multiple can be slow.
You can save time with an option or one-line configuration change.
For some repositories, I routinely work with a lot of Git remotes.
When there are more than 5 remotes, running git fetch --all can take over ten seconds.
I was starting to get annoyed about this.
When I dug into the Git docs, I found that Git can safely run run fetches in parallel.
On the command line, you can use the --jobs=<n> option to run <n> fetches at once.
In your Git configuration, you can set fetch.parallel to do this automatically:
[fetch]
parallel = 0
According to the documentation, the config value 0 gives “some reasonable default.”
It’s not documented, but this also works on the command line with --jobs=0.
This appears to be based on the number of CPU cores.
For me, this reduced the typical time for git fetch --all to less than two seconds.
See also: My first Git commit
“Assert your way to stronger technical writing” by Jason McIntosh describes how to use an “assertions document” to coax subject matter experts (SMEs) to reveal what they know.
Jason frames the approach this way:
This assertions document is a way to help me understand the technology I’m writing about.
I make several confident-sounding statements that describe my nearest understanding of various facets of this technology, and invite you to correct me, or name some points that I am missing.
Jason’s approach to gaining information through correction is one of the more transparent versions I’ve seen.
Other approaches include writing a presumed falsehood into a draft or asking a subject matter expert an “obvious” or “stupid” question.
Tech writers often exploit the burning desire to be right to get information from people who have it, much like the victim in the (in)famous xkcd comic “Duty Calls.”
None of my excellent instructors or mentors taught me to do this and I don’t think I’ve taught anyone else how to do it either.
It seems that all tech writers find their own way to instrumentalizing Cunningham’s Law, which states:
The best way to get the right answer on the internet is not to ask a question; it’s to post the wrong answer.
“Against Access” by DeafBlind poet and author John Lee Clark is an arresting essay about the often one-way street of accessibility.
Clark writes:
Such a frenzy around access is suffocating.
I want to tell them, Listen, I don’t care about your whatever.
But the desperation on their breath holds me dumbfounded.
The arrogance is astounding.
Why is it always about them?
Why is it about their including or not including us?
Why is it never about us and whether or not we include them?
You should read the whole thing, but I’m going to focus here on an area of my professional responsibility.
Clark writes about the demand for video description and alt text:
In recent years, there has been a rush on the internet to supply image descriptions and to call out those who don’t.
This may be an example of community accountability at work, but it’s striking to observe that those doing the most fierce calling out or correcting are sighted people.
Such efforts are largely self-defeating.
I cannot count the times I’ve stopped reading a video transcript because it started with a dense word picture.
Even if a description is short and well done, I often wish there were no description at all.
I’m taking Clark’s words as a license to be even more assertive about the way I write alt text.
I sometimes write “word pictures” (more in the past but now and again when I’m backsliding).
But I’ve been trying to treat alt text as a way to capture the meaning of an image and my intention for including it instead of a description of the image’s contents.
Clark’s essay is a reminder that alt text is not some lesser substitute.
It is an opportunity to write directly to and for a parallel audience.
In October, I helped organize the Write the Docs Berlin 2025 conference.
In between my staff duties, I found some time to properly enjoy the conference too.
Here are my personal highlights from the conference.
-
Kat Stoica Ostenfeld’s talk, “So you’ve become a docs lead. Now what?”
This talk teaches managers and non-managers alike about how and why managers speak the way they do and how their work differs from so-called individual contributors.
It's informative and very funny.
Also, I’m going to steal a tiny bit of credit here:
Kat and I have a regularly-scheduled call where we talk about docs work.
On one of our calls, Kat suggested this topic and I might have somewhat aggressively advocated for submitting it.
I’m glad I did!
-
The Florian Stolzenhain’s unconference session, Scraping & Parsing “Small” Data.
We talked strategies for scraping (like raw data retention), good intermediate formats (such as SQLite and JSON), and how to generate useful web pages and data tables.
Apparently, web scraping and data aggregation brings people together who care about doing things that are time, energy, and bandwidth efficient.
For example, Nemo showed off Jekyll SQLite and endoflife.date, Florian demoed efficient data tables using List.js, and I talked a bit about using the bkt cache to explore data sources.
This was one of those peak Write the Docs experiences: connecting with people who care about the same things I do, before even realizing that it was a distinct thing that I cared about before sitting down at the table.
-
My lightning talk, “I want your wholesome self promotion.”
Speaking as a moderator for the Write the Docs Slack chat, I explained how to share your work in a community-friendly way.
I had not spoken on stage at a Write the Docs event since 2017 and forgot how good the Write the Docs audience is (they laughed at all my jokes).
As an organizer, I was a little nervous being in an unfamiliar venue after Write the Docs’s long (European) in-person hiatus, but the staff and volunteers did an outstanding job putting on a worthy iteration of the conference.
I’m hopeful that the next one will be sooner than the one before it.
Recently, I wrote 5 things I know about automating docs with GitHub Actions.
Since writing it, I learned about three more static analysis tools that might help you create workflows for GitHub Actions.
In that post, I suggested using ShellCheck with actionlint and I still do.
But there are a few more tools that you might consider using too:
- zizmor: When I ran this against a project I contribute to, it found excessive permissions that other tools did not.
- poutine: Running this tool found a potentially risky pipe from
curl that other tools did not.
- claws: Running this tool warned about using
workflow_dispatch, which I’m not really worried about for my use cases but is food for thought.
I learned about all of these via Dear GitHub: no YAML anchors, please by William Woodruff, maintainer of zizmor.
It’s an interesting read about a nasty bit of YAML syntax that you should probably stay away from.
For years I’ve been a volunteer moderator on the Write the Docs Slack.
Here are four things I try to remind myself when it comes to moderating the Write the Docs chat:
-
My responsibility is to the community, not the rules.
My job is to create expectations and norms that allow helpful discussions to flourish and make hostile messages and fights unlikely or inconvenient.
My job is not to make individual people behave correctly or act against every undesirable thing that might happen.
-
Don’t work alone.
If my job is the community then it stands to reason that the moderation work should be communal as well.
I work with other moderators and infrequently take action without informing or consulting them first.
And as a practical matter, moderation work often involves bad feelings and ambiguity.
Working alone intensifies them; working with others weakens them.
-
Moderate in public.
This is where not working alone and responsibility to the community meet.
When it’s reasonable to do it, I put the moderation process in public.
For example, the Write the Docs Slack has a #meta channel for publicly discussing the chat community itself, so we can answer questions, change guidelines, and create new channels as a community.
Moderating in public also means that I sometimes have to tell people to change their behavior in public.
That feels bad sometimes but it’s part of the process of creating community expectations that the community understands and shares.
-
Routinize kindness.
I wield powers that other members of the community do not.
There is no need for me to hold this over them (and it’s probably bad for me to let it go to my head).
So until I have evidence to the contrary, I assume that most people mean well, want to do well, and are willing to join me in doing so for the benefit of the community.
I try to follow a pattern that shows that I recognize a participant’s motivations and wish to help them align those motivations with the community’s needs.
If you’re like a lot of people (myself included), then you often use search engines to navigate to known resources, rather than to find new information.
But you might have found this works less often than it used to.
Here’s a partial solution: you should use browser bookmarks more.
I often use a search engine to navigate to known-to-me pages, rather than to search for new information.
But I’m increasingly dissatisfied with search results.
There’s more junk in search results pages than ever: irrelevant ads, low-quality generated summaries, and badly-generated “organic” search results.
Instead of search, I’ve started using bookmarks to return to those known-good pages more reliably.
While I’ve got some carefully organized bookmarks, supplanting my search engine use has pushed me to change my bookmarking strategy.
I now favor speed of bookmarking and likelihood of recall over taxonomy and regularity.
Here’s how I bookmark things for navigation:
-
Create a bookmarks folder named for the current year.
-
When I come to a page that I think I might wish to revisit (or, more likely, have already visited once before and I am now revisiting it), click the bookmark icon in the URL bar.
The bookmark interface appears.
-
Choose the current year folder.
At the time I’m writing this, Chrome and Firefox persist the folder selection, so the next time I bookmark something I won’t have to choose the folder.
-
This is the critical step.
In the bookmark’s Name field, append the terms I used to find that page, notes about how I arrived to it, or memorable words or phrases.
If I follow these steps, then the next time I use my browser’s URL bar to search for that information, it ought to appear in the list of search completions.
Often the page title does not contain the search terms I used, so appending my own text into the bookmark name means its more likely to appear in those completions.
Incidentally, this is why searching my browser history routinely lets me down: history page titles lack the context and content of the visited page.
If you’re a browser vendor product manager, I’m begging you: add full-text search to your browser history.
See also: You should use mktemp more.
“If there are no speaker notes on a slide, then you don’t need that slide.”
Erin McKean said this to me while we were rehearsing our talk for Open Source Summit Europe 2025.
It’s good advice and I thought I might be more likely to remember it if I wrote it down.
I’ll take this opportunity to share a slide deck tip of my own:
the slides that you see don’t have to map 1:1 to the slides your audience sees.
If you have too many notes for a single slide, then duplicate the slide and split up the notes across the two slides.
This way you can make the notes font bigger, emphasize a point, or give yourself some stage direction.
I use a linter to “force” me to resolve incomplete tasks.
When I’m writing new text, configuration, or code, I leave comments prefixed with XXX to remind myself to complete unfinished tasks.
Later, a script turns those comments into errors.
This helps me address the most interesting or challenging parts of the work first, without breaking context to put incremental to-do items into OmniFocus or a GitHub issue.
While I have long used TODO comments, I did not know to fail a test on the presence of such comments until a couple of years ago.
I learned this practice from Execute Program’s blog post, “The Code is the To-Do List,” which goes into greater depth about why you would do this and how to do it for JavaScript projects specifically.
While a lot of my work happens in JavaScript code, a larger fraction happens in YAML and Markdown files.
As far as I know, there’s no off-the-shelf tool to lint YAML or HTML comments.
But writing a script for this wasn’t a particularly challenging exercise.
The core of that script is a set of if statements like this one that checks for comments that contain XXX and emits an error if found:
# For YAML files and Markdown frontmatter, look for `# …` style comments
if grep --ignore-case '^\s*# XXX.*' "$file" > /dev/null; then
echo "ERROR: $file contains XXX comment"
error_count=$((error_count + 1))
exit_code=1
fi
Depending on the project, a script that does this is part of the shared continuous integration tests or a personal pre-push hook.
mktemp makes temporary files with unique names, which is great for ephemeral searches and tests.
You should use mktemp more.
I recently read “You Should Use /tmp/ More.”
Marc writes about using the directory on Linux (and the same goes, roughly, for macOS):
One directory that I think should really get more attention and one that I keep finding new use-cases for is the /tmp/ directory.
Intended for temporary files, /tmp/ is typically where 'stuff' that a program doesn't need in the long-term goes: temporary backups of files you're working on, your browser caching some content, a holding place for in-progress updates.
/tmp/ also has the unique benefit that it is cleared whenever your machine is reset - it is temporary after all.
Marc’s right: you should use your temporary directory more.
One way I’ve been doing that has been with the help of mktemp.
For example, suppose I want to grep a GitHub repository.
I’ll temporarily clone the repo into a temporary directory, like this:
cd $(mktemp --directory) && \
gh repo clone mdn/content -- --depth=1
Here's a convenient pattern: create a temporary directory, do some work inside it, then print the directory path in case I want to inspect the work more closely.
For instance, suppose I’m filing a bug report and I want to include reproduction steps.
I’ll often do it as a script like this:
#!/usr/bin/env fish
set owner "someorg"
set repo "somerepo"
# make a directory in the pattern of:
# `someorg-somerepo.XXX`
set tempdir (mktemp --directory -t $owner-$repo)
gh repo clone $owner/$repo $tempdir -- depth=1
# reproduction steps go here
echo $tempdir
Then I can run the script repeatedly until I can reliably produce the results I want, forget all about the clean up between or after runs, and share executable code that helps the bug get fixed (see a recent real-world example).
Tidy!
Sometimes American websites won’t accept an international number or a US-based VoIP number for registration and two-factor authentication.
I recently found a workaround using a US-based mobile network and Wi-Fi calling.
For over 20 years, I’ve had the same phone number in the United States.
When I moved abroad, I ported the number to Google Voice.
But Chase Bank and others now frequently reject both my Google Voice number and my Dutch number.
To work around this, I got a second SIM from a US-based MVNO called Tello, which allows their customers to:
- Get an eSIM while abroad.
- Keep a line active indefinitely without being physically present in the US.
- Use Wi-Fi calling to send and receive calls and texts without paying roaming fees.
Since Tello has actively promoted this use case, I don’t expect them to pull the rug out from under me (at least not soon).
Critically, the service is inexpensive.
I’m currently paying about $5 per month for some minutes and unlimited texts.
It’s a small price to pay to avoid frequent log-in hassles.
As a bonus, I can temporarily add a data package when I visit the US, as I did on my most-recent trip.
Setting it up was straightforward but for one exception.
At first, Tello wouldn’t connect through my home Wi-Fi network.
After scouring various forums, I reluctantly switched my home router to different DNS server and rebooted my phone.
For reasons I don’t understand, it worked.
It appears that any of the big public DNS resolvers, such as Quad9’s, Google’s, or Cloudflare’s, does the trick.
While traveling, I needed to wake a computer sleeping at home to get a file that I left behind.
You can’t usually wake a device over the internet, but with Tailscale and a little bit of sleuthing, I was able to use my Synology NAS to remotely find my sleeping computer’s MAC address and send a Wake-on-LAN packet.
On a recent trip, I found that I wanted a file from a computer running at home, but long after it went to sleep.
I knew that if could just send a Wake-on-LAN (WoL) packet to the sleeping computer, I’d be able to use remote desktop to get the file.
But WoL only works on the data link layer.
I would need to send the magic packet from a computer already awake on my home network.
Before leaving, two things were in place to make this set of problems surmountable.
First, my devices (both sleeping and awake) were already set up to use Tailscale, a service that creates a private WireGuard mesh network.
Second, I have an always-on home file server, a Synology network-attached storage (NAS) device.
Unfortunately, Tailscale can’t send a WoL packet for you (there’s a feature request for that).
But even if it could, I had another problem: I didn’t have the MAC address of the computer, needed to send a WoL packet.
I would need to find the MAC address first, then send a WoL packet from the NAS.
After some thought, there was one place I knew my sleeping computer’s MAC address would be recorded: in my router’s DHCP logs.
But my router isn’t connected to Tailscale; it isn’t “in my tailnet” in Tailscale’s terminology.
But Tailscale does have an answer to this problem: I turned on the subnet router setting for my NAS, which allows devices in my tailnet to reach neighboring devices that cannot join the tailnet directly.
With subnet routing, I was able to remotely access the router admin page and find my sleeping computer’s MAC address.
With that problem solved, sending the WoL packet from my NAS was relatively uncomplicated.
To do that, I signed into the NAS’s admin and turned on the Enable SSH service setting.
Then I SSH’ed into the NAS and ran the (proprietary) Synology command, synonet --wake $macaddr eth0, where $macaddr was my sleeping computer’s MAC address.
A moment later, the computer woke up and connected to my tailnet.
A brief remote desktop session later, I had my file.
See also: Revisiting remote Wake-on-LAN
Fuck Starlink is a protest tool to geoblock astroblock users of SpaceX’s mobile internet service from your website.
Set aside the noxious corruption and politics of the world’s richest defense contractor for a moment not because it’s not important but because you’re already well aware.
Instead, take note of another set of issues that Fuck Starlink emphasizes:
Swarms of satellites in low Earth orbit are a blight on the sky and the Earth.
These satellites are interrupting astronomy and casual stargazing, generating literal tons of space debris, and dumping ozone-depleting metals into the atmosphere.
It’s not great.
But it’s also not well known.
I hope Fuck Starlink finds some success in helping Starlink customers rethink their internet access.
The bookends to Andrea Kao’s FOSS Backstage talk, Abundant with life: docs beyond the wall, are a word, content.
Andrea says:
When we refer to “content” we’re referring to something that seems universally replaceable, reproducible, interchangeable, shapeless, formless, stuff.
And we end up reducing ourselves…
It’s not as if I never say it, but I’ve long been uncomfortable with the word “content” to refer to the docs, essays, and other artifacts of my work.
But I had thought of it in terms of diminishing the work itself.
Andrea makes a sharp point that diminishing the work diminishes the worker too.
It reduces agency, increases a sense of passivity, and limits the connections between the people who make the docs.
I recently learned about bkt, a command-line tool for caching processes.
It saves needless work and makes slow processes feel fast.
I often make an API request with curl, then pretty print the result with jq.
Take this example, where I fetch Wordnik’s word of the day:
curl --silent \
"https://api.wordnik.com/v4/words.json/wordOfTheDay?api_key=$WORDNIK_API_KEY" \
| jq .
Now, suppose I want to experiment with this a bit, transforming the result with jq.
Every time I rerun curl, it’ll make a needless request, since the word of the day probably hasn’t changed in the last minute.
I could write the response to disk, then run jq on it (or use a tool like ijq), but I’m a little lazy and I like being able to search my shell history for the API endpoint.
Instead, I can prepend my API request with bkt, saving and replaying the response through multiple runs, like this:
bkt --ttl=8h -- curl --silent \
"https://api.wordnik.com/v4/words.json/wordOfTheDay?api_key=$WORDNIK_API_KEY" \
| jq --raw-output '.word, .definitions[].text'
Since learning of this tool, I’ve found that I run lots of commands that are a little slow (one to three seconds) but the output changes infrequently (maybe one or two times per day).
These are ideal for running with bkt.
For example, I use Alfred, a macOS application launcher, for lots of things.
It mostly feels instantaneous, but sometimes I run a just-slow-enough custom workflow script that breaks the illusion.
Prepending some scripts with bkt restores the illusion.
One common case is find.
I sometimes use find to find and filter files that Alfred’s built-in tools aren’t suited for.
While traversing lots of directories can be slow, bkt can cover for for this fact.
For example, this bkt invocation finds every Git repository in my home directory without waiting seconds to exit:
bkt --ttl=1d --stale=8h -- \
find $HOME/code -type d -name .git -prune -exec dirname {} \;
bkt has a number of options to help different caching scenarios.
For more, check out bkt’s (refreshingly well-written) README.
Unicode (and ASCII before it) provides several obscure separator characters, such as ␜, ␝, ␞, and ␟.
These are very rarely necessary, but nice to have sometimes.
For instance, if you need a separator character that’s exceedingly unlikely to conflict with field data in a CSV file, then it’s nice to have the record separator character around.
What I did not know until today was that Unicode also provides control character picture characters for representing the generally unprintable control characters visually.
I suspect these are even more rarely useful, but nice to have when you want to write about the characters themselves, as I'm doing right now.
I learned about these characters thanks to text.makeup. ␜
I’ve been a long-time user of the fish shell.
Thanks to the recently-released 4.0 version, I was reminded of something that’s really great about fish: the abbr command.
abbr creates abbreviations, aliases that expand when typed into an interactive shell session.
For example, this command expands @dotfiles into the path to my dotfiles repository:
abbr --add --position anywhere \
-- @dotfiles '~/code/personal/dotfiles/'
If I type cd @dotfiles followed by Enter or Space, fish expands it into cd ~/code/personal/dotfiles/.
Unlike a fish function or Bash alias, this text is expanded visually in the terminal and the shell history.
In fish 4.0, you can target abbreviations to a specific command, too.
For example, this command expands git sw into git switch:
abbr --add --command git -- sw switch
This abbreviation works only with the git command; sw foo does not expand into switch foo.
Abbreviations are ideal for adding shortcuts or overriding defaults for programs with lots of fiddly options, such as Git.
Abbreviations have a bunch of benefits over a function or Git alias:
-
Abbreviations make it easier to learn and remember what complex commands do.
It’s easy to forget how a function or alias works because it hides all the switches.
With an abbreviation, you get the benefit of a function or alias, while still being able to see what it’s doing.
-
I don’t have to remember the defaults, if I want to temporarily revert the behavior defined by my abbreviation.
It’s right there in my shell history.
-
The shell history is easier to search.
If I want to search my history for a command, there’s no possibility of missing a useful result because I searched for an alias instead of the full-length command.
-
It’s easier to share commands or excerpts from shell sessions.
I can copy and paste from my history, without having to explain (or manually expand) functions and aliases.
With the new abbr options in fish 4.0, I replaced several Git aliases with abbreviations.
It’s nice to be (re)learning some corners of Git that I had obscured from myself.
I’m a volunteer moderator on the Write the Docs Slack.
Sometimes people do things that break our rules and community norms.
Usually, clear and kind communication about how to correct the problem solves it permanently, with no repeat or follow up action required.
This is how I do it.
I have a checklist for composing a moderation notice, naturally.
It looks like this:
- Greet the subject of moderation, the poster.
- Identify myself as a community moderator.
- Describe what action I am taking and where.
- Recognize the poster’s motivation and humanity.
- Say that the behavior is not OK because it goes against rules.
- Name the rules or guidelines that apply.
- Provide a remediation action.
- Open the door to questions and support.
- Say thank you.
Suppose someone has crossposted to too many channels.
I might delete the offending messages and send this note:
Hi Poster McPostface.
I’m Daniel, a moderator.
I wanted to let you know that I deleted your message in the #general channel.
I know you’re excited to talk about this topic, but your posts go against our Slack guidelines — specifically, it violated our rules against excessive crossposting.
In the future, please share your questions in one channel at a time and wait to see if there’s a response before crossposting.
Drop a note in the #meta channel, if you have any questions about the guidelines.
Thanks for your understanding!
Most people mean well and want to do well.
This is how I help them do it.
Thanks to my fellow moderators Janine Chan and Ravind Kumar for essential feedback on this post.
There are so many stories hidden within all the codepoints, and so much strange complexity.
Marcin Wichary made and shared a fun and interesting tool for inspecting Unicode called text.makeup.
Start typing into the text box and it’ll show you which Unicode character you just typed.
But it’ll also show you interesting observations about that character.
For example, if you type h, you’ll learn that it’s the character Latin Small Letter H, naturally.
But it will also point out that this character is similar-looking to many others—it’s one of several h-like homoglyphs—and why that’s interesting.
This is just one of several things it can tell you about characters.
As a Sunday-morning “chore,” I added skip links to this site.
Using the Tab ↹ key to navigate the page now shows a “Skip navigation” link that jumps to the main contents of the page.
Skip links are convenient for keyboard users and users of assistive technology, such as screen readers.
When I recently redeveloped this site, I knew that I needed skip links but I didn’t know how to make them.
I found these resources helpful:
My skip link only appears on focus, to avoid adding more visual navigation when it’s not needed.
When testing this, I discovered that the tab order of my navigation was confusingly incongruent with the page’s visual order, so I fixed that too.
Not bad for a Sunday morning.
What if something important to you disappeared from the web? DIY Web Archiving is a zine about why (and how) you should preserve things on the web that matter to you, by Quinn Dombrowski, Tessa Walsh, Anna Kijas, Ilya Kreymer, and Amanda Wyatt Visconti.
There are lots of tools and guides to web archiving out there, but what I like best about this one is its coverage of metadata, with short and sweet advice on choosing file names and essential facts to record about anything you might archive.
With successful ongoing attacks on archives, it’s more important than ever to make independent archives of works on the web, especially your own.
I recently revisited JoAnn T. Hackos’s Information Process Maturity Model (IPMM, full-text PDF).
The IPMM defines five levels for organizations that produce documentation, ranging from “ad-hoc” to “optimizing.”
The IPMM originates (mostly) with Hackos’s 1994 book Managing Your Documentation Projects.
By predating Google, the Agile Manifesto, and publishing on the web first (and in print practically never), it’s a fascinating artifact to see what has and has not stood the test of time.
Setting aside the waterfall worldview, I am struck by how much I dislike the IPMM’s view of the lower levels of immaturity, though I respect the hustle for resources.
So much of my work is open source where “mature” process brought on too early can interrupt healthy exploratory work and community building.
Many projects should have “immature” docs processes, especially when they’re still in a self-discovery phase.
In any case, the IPMM was both inspirational and anti-inspirational for my open source documentation maturity checklist.
In that checklist, I was more concerned with the ways that open source documentation work meshes with the development of the software itself, rather than docs production in isolation.
“Effective immediately, STC will permanently close its doors and cease all activities.”
It’s a shock to see that the Society for Technical Communication has gone.
It has been a long time since I was involved in STC but it seemed to me an institution that would persist because it had been so long in the habit of doing so.
If you’re a now-former Society member, I’m sorry about what you’ve lost.
No one thing will take its place.
In the near term, I hope you reach out to your fellow chapter members and find a way to preserve your community informally, if only because starting over is harder.
If you’re working in software documentation and lacking a community to call home, I’d like to invite you to join your nearest Write the Docs meetup, conference, or the global Slack chat.
I’m a volunteer moderator on the chat but speaking only for myself here.
WTD’s narrower in focus, with a different vibe and ethos, but if your work coincides even a little with what we’re doing, then you’re likely to find some of your peers with us.
Moderating news intake is a departure from my usual topics, but I recently took some longer notes on managing my attention while still being well-informed, to share with my friends in the Indy Hall community.
If you’re thinking about this problem, then perhaps you’d like to compare notes.
Last year the NumPy project made a zine about how to contribute to NumPy (via Contribute to NumPy).
I like how it’s honest about how knowledge of an open source project is shared between all users and contributors, not held centrally by a few core maintainers.