sc
Scott avatar
ttwillsey

Grin and Bear It

Mac

Part of the Mac series

Back when the iPad Pro was my main mobile “work” device,1 Drafts played a huge role in my writing and blogging. For one thing, it has extensive automation and scripting capabilities, and those came in super handy on iPadOS. I created automations to let me choose photos from my photos library and add them to blog posts, including creating proper links and adding them to my site repo.

But now I have an Apple Silicon MacBook Pro and I’ve long since given up trying to fight my way to success through all the limitations of iPadOS. It’s hard to overstate how much faster and easier so many things are on the Mac, and given the current state of Apple’s Shortcuts, having tons of other reliable methods of automating things, as you do on the Mac, is incredibly nice.

All that to say, I realized today I haven’t really done anything in Drafts except type out blog posts and then paste them into VSCode when it was time to publish them. I’m no longer making use of all the great Drafts automations and integrations that I used to. And given THAT fact, I may as well do my writing in something that looks nicer and feels more modern as a writing app.

I own iA Writer but it bugs me in certain ways. I don’t like the non-standard footnote default that makes you manually type the footnote reference at the bottom yourself if you don’t want an inline footnote, and it’s also very limited on font choices.

Ulysses is also weird. It shows you some of the Markdown but tries to hide other Markdown, like URLs, in very inconvenient ways. It’s been a long time since I thought I was a fan of Ulysses.

Before Ulysses, it was Byword for me. To say it looks a little basic now is a bit of an understatement.

That leaves Bear as the only realistic Markdown writing option for me, and I have to say, I like how the editor looks. That’s important to me. If it wasn’t, I’d still be using Drafts because it’s a great app and I do really like Greg Pierce and the work he does on it. It’s just not as important to me on the Mac now as it was on iPadOS.

I’m a person who has a lot of idiosyncrasies, and one of them is that I need a nice looking editor to be able to enjoy the writing process. Bear definitely looks nice. The defaults are nice, customizing it is simple, and things like images and links look really nice in it.

Below is an image showing both the light and dark themes I’m currently using in Bear.

My Bear dark and light themes

There’s not a lot I need to do in order to incorporate Bear into my writing workflow. I already have a script that names my images and puts copies of them in the right locations to both be optimized for the blog and to be able to link to the original. I’ll need to put an automation somewhere in the publish chain that updates the post’s Markdown file with those image paths and creates the links to the larger, original images. But that was also something I needed to do for my Drafts workflow, and hadn’t yet.

Also I think playing with Bear has given me some ideas for improving my site CSS a bit… 🤔

Footnotes

  1. Not my WORK work device, but my personal project work device

Auto-Generated Last Modified Date in Astro

Part of the Astro series

I’m trying to figure out how to use remark plugins in Astro to modify a couple things in posts for me automatically, and along the way I’ve used remark to add a couple quality of life improvements. The first is an auto-generated table of contents for longer posts that I feel need one, and the second is an auto-generated last modified date for pages based on git commit timestamps.

The benefit of an automatically generated last modified date is that I don’t have to remember to update it when I make changes to a page that displays it, like my now page or my links page. I can just commit my changes and the last modified date will update automatically. It’s simple, but it’s kind of beautiful.

I basically did exactly what Astro’s documentation says to do in a helpful recipe called Add last modified time. I created a file called remark-modified-time.mjs and put it in my src/components/utilities folder.1

remark-modified-time.mjs
import { execSync } from "child_process";
export function remarkModifiedTime() {
return function (tree, file) {
const filepath = file.history[0];
const result = execSync(`git log -1 --pretty="format:%cI" "${filepath}"`);
file.data.astro.frontmatter.lastModified = result.toString();
};
}

Then I updated my astro.config.mjs file to reference this as a remark plugin, and to use it in my markdown processing.

astro.config.mjs
import { defineConfig } from "astro/config";
import expressiveCode from "astro-expressive-code";
import icon from "astro-icon";
import pagefind from "astro-pagefind";
import { remarkModifiedTime } from './src/components/utilities/remark-modified-time.mjs';
import remarkToc from 'remark-toc';
/** @type {import('astro-expressive-code').AstroExpressiveCodeOptions} */
const astroExpressiveCodeOptions = {
// Example: Change the themes
themes: ["material-theme-ocean", "light-plus", "github-dark-dimmed"],
themeCssSelector: (theme) => `[data-theme='${theme.name}']`,
}
// https://astro.build/config
export default defineConfig({
site: "https://scottwillsey.com/",
integrations: [expressiveCode(astroExpressiveCodeOptions), icon(), pagefind()],
markdown: {
remarkPlugins: [ remarkModifiedTime, [remarkToc, { heading: "contents" } ] ],
},
});

Once those are set up, it’s just a matter of referencing remarkPluginFrontMatter.lastModified in the Astro template.

now.astro
---
import { getEntry } from "astro:content";
import { Icon } from "astro-icon/components";
import Base from "../layouts/Base.astro";
import { modifieddate } from "../components/utilities/DateFormat.js";
const now = await getEntry("now", "now");
const { Content, remarkPluginFrontmatter } = await now.render();
let title = now.data.title;
let description = now.data.description;
---
<Base title={title} description={description}>
<article>
<h1>{title}</h1>
<p class="now">
The <a href="https://nownownow.com/about">"now page"</a> concept comes from
an idea by <a href="https://sive.rs">Derek Sivers</a> to have people communicate
what they're focused on <b>now</b> at this point in their lives.
</p>
<div class="time">
<Icon name="bi:calendar2-week-fill" />
<time datetime={now.data.date}>
<a href={`/${now.slug}`}
>Last updated {modifieddate(remarkPluginFrontmatter.lastModified)}</a
>
</time>
</div>
<div class="now">
<Content />
</div>
</article>
</Base>

That’s it. Now I have auto-generated last modified dates for any page I feel needs one, thanks to Astro using remark to render markdown and therefore making adding remark plugins super simple.

Last updated indicator

Footnotes

  1. You can put your remark plugin anywhere you want, as long as you reference the path correctly in astro.config.mjs.

Now Page

I don’t remember how I stumbled across the now page movement started by Derek Sivers, but I immediately thought “what a great idea!” and decided to add one to my site.

The concept is simple – it’s a page that lists some current interests, activities, or projects that you’re into or doing. I decided to title mine “Current and Recent Things” because it seemed more appropriate when talking about food I’ve recently enjoyed or movies I’ve recently watched to label those as “recent” instead of “right this second”.

This is the kind of thing I like about personal websites. I think I a lot of people are realizing that blogging isn’t dead yet, it’s not even just resting, and additions to personal sites like now pages make personal blogs and websites even more fun to maintain.

You should try it, if you’re not already.

Anyway… here it is. My now page.

Default Browser Switching

Raycast

Part of the Raycast series

As you saw from my Default Apps December 2023 post, I use Safari for personal use and Chrome for web work and for some administrative and server-related tools that work best in it. The downside to this is that links go to the default browser from things like 1Password, Fastmarks, and email messages, for example. This means when I’m using Chrome, what I really need is for it to be my default browser, and the rest of the time, Safari to be.

Because I’m a Raycast user, I was intrigued by the Raycast Script Commands GitHub repo. The script command examples in this repo include system commands that include default browser scripts for Arc, Chrome, Chromium, Firefox, and Safari.

Raycast Script Commands

Raycast script commands are basically scripts that are registered in Raycast and have hooks that let it interact with them to pass parameters and show output in Raycast, if desired. These can be Bash scripts, AppleScript, Swift, Python, Ruby, or Node.js scripts.

Raycast Create Script Command menu

Raycast Default Browser Script Commands

The default browser scripts in the Raycast script command repo rely on a very short Objective-C program that you compile on your Mac called defaultbrowser, which lets you change your default browser from the command line. Given this, you might be surprised to learn that these script commands are AppleScript, and not Bash scripts. The reason is simple: buttons.

When you tell your Mac to switch default browsers using defaultbrowser, you are presented with a dialog box giving you the option to set whatever browser you wanted as your new default, or to keep whatever browser is the current default. Bash scripts can’t click buttons, but AppleScript can. It can also run Bash scripts, which lets us call defaultbrowser from within the AppleScript.

Here’s the default-browser-safari.applescript script from the Raycast script commands repo:

#!/usr/bin/osascript
# Dependency: requires defaultbrowser (https://github.com/kerma/defaultbrowser)
# Install via Homebrew: `brew install defaultbrowser`
# Required parameters:
# @raycast.schemaVersion 1
# @raycast.title Default to Safari
# @raycast.mode silent
# @raycast.packageName Browser
# Optional parameters:
# @raycast.icon images/safari.png
# Documentation:
# @raycast.author Marcos Sánchez-Dehesa
# @raycast.authorURL https://github.com/dehesa
# @raycast.description Set Safari as the default browser.
set repeatCount to 0
tell application "System Events"
try
my changeDefaultBrowser("safari")
repeat until button 2 of window 1 of process "CoreServicesUIAgent" exists
delay 0.01
set repeatCount to repeatCount + 1
if repeatCount = 15 then exit repeat
end repeat
try
click button 2 of window 1 of process "CoreServicesUIAgent"
log "Safari is now your default browser"
on error
log "Safari is already your default browser"
end try
on error
log "The \"defaultbrowser\" CLI tool is required: https://github.com/kerma/defaultbrowser

The top half is information for Raycast. Then the AppleScript portion gets going. First it calls a function1 that runs the defaultbrowser command line program with the string safari as a parameter value. Then it runs a loop waiting for the confirmation dialog box to pop up. It waits until either the window exists or it’s looped 15 times. Finally, it tries to click button 2 of the dialog box. The reason it tries to click button 2 is because the dialog looks like the following image – or at least, it’s supposed to. More on that later.

Default browser change confirmation dialog box

This means when you send it a request to change your default browser from Safari to Chrome, for example, you’ll get a dialog box with two buttons, the first of which cancels the change and the second of which executes the change to Chrome.

I created new Raycast script commands using the appropriately named “Create Script Command” command in Raycast, and then copied the AppleScripts from the repo into them. Hooray! End of blog post, right?

Except there was a problem.

Chrome Does It Differently

Everything worked great whenever I tried switching from any browser that wasn’t Chrome to any other browser. But whenever I tried switching FROM Chrome back to something else, it never worked.

A simple test using defaultbrowser directly in the command line showed me why. Chrome’s confirmation dialog box is different. It looks like this:

Dialog box when switching from Chrome to a different default browser

You see the problem. The command scripts assume that button 2 is the button for making the switch, but in the case of Chrome’s dialog box, it’s the other way around. Button 1 makes the change and button 2 keeps the current default browser setting.

I honestly don’t know whose responsibility this is, Google’s or Apple’s, and I don’t really care. I presume it’s Google’s because I presume at one point the Raycast versions of these script commands worked, even when switching away from Chrome as the default browser, so my guess is Google changed something about Chrome’s confirmation dialog. I don’t know. Honestly, I would have thought this was entirely handled by macOS.

Modifying the Default Browser Scripts to Handle the Chrome Dialog Box

The solution is simple: if switching from Chrome, click button 1. Otherwise, click button 2. Since the script could be switching to a non-Chrome browser from a different non-Chrome browser, this means I need to check. And that sent me down a rabbit hole that I refuse to admit the time duration of, because it was long enough to need to add an “s” to “hour”.

But it goes like this:

On macOS, your default browser setting is one of many settings saved in com.apple.launchservices.secure.plist in your local Library/Prefences folder (~/Library/Preferences). You can search for it. If you type the following, you’ll get a LONG output that includes two lines you care about:

Terminal window
plutil -p ~/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist

Those two lines are:

Terminal window
"LSHandlerRoleAll" => "com.apple.safari"
"LSHandlerURLScheme" => "https"

That’s if Safari is your current default browser, of course. It could be org.mozilla.firefox or com.google.chrome, for example. But you want to search for a line with LSHandlerRoleAll set to some browser, followed by a line called LSHandlerURLScheme set to https.

Fortunately, awk was made for things like this, and also fortunately, AppleScript can run shell commands. So I made another function for my default browser AppleScripts to see what the current browser is.

to getCurrentDefaultBrowser()
set filePath to "~/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist"
set output to do shell script "plutil -p " & filePath & " | awk '/LSHandlerRoleAll/{a=$3}/LSHandlerURLScheme/{if($3==\"\\\"https\\\"\") print a}'"
return output
end getCurrentDefaultBrowser

Now, before running the part of the script that runs defaultbrowser before waiting for a button to click, it checks which browser is the current default browser. If it’s com.google.chrome, then my script clicks button 1 for me. Otherwise it clicks button 2.

#!/usr/bin/osascript
# Dependency: requires defaultbrowser (https://github.com/kerma/defaultbrowser)
# Install via Homebrew: `brew install defaultbrowser`
# Required parameters:
# @raycast.schemaVersion 1
# @raycast.title Set Default Safari
# @raycast.mode silent
# @raycast.packageName Browser
# Optional parameters:
# @raycast.icon images/safari.png
# Documentation:
# @raycast.author scott_willsey
# @raycast.authorURL https://raycast.com/scott_willsey
set currentDefaultBrowser to my getCurrentDefaultBrowser()
set repeatCount to 0
tell application "System Events"
try
my changeDefaultBrowser()
repeat until button 2 of window 1 of process "CoreServicesUIAgent" exists
delay 0.01
set repeatCount to repeatCount + 1
if repeatCount = 15 then exit repeat
end repeat
try
if currentDefaultBrowser contains "com.google.chrome" then
click button 1 of window 1 of process "CoreServicesUIAgent"
else
click button 2 of window 1 of process "CoreServicesUIAgent"
end if
log "Safari is now your default browser"
on error
log "Safari is already your default browser"
end try
on error
log "The \"defaultbrowser\" CLI tool is required: https://github.com/kerma/defaultbrowser"
end try
end tell
to getCurrentDefaultBrowser()
set filePath to "~/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist"
set output to do shell script "plutil -p " & filePath & " | awk '/LSHandlerRoleAll/{a=$3}/LSHandlerURLScheme/{if($3==\"\\\"https\\\"\") print a}'"
return output
end getCurrentDefaultBrowser
to changeDefaultBrowser()
do shell script "
if ! command -v defaultbrowser &> /dev/null; then
exit 1
fi
defaultbrowser " & "safari" & "
exit 0
"
end changeDefaultBrowser

Now when I have Chrome set as my default browser and I don’t want that anymore, my Raycast default browser command scripts work as intended, and will actually manage to set my default browser to the desired one.

A Parameterized Version of the Default Browser Command Script

Just in case anyone else cares, I also created a parameterized version of the script that lets me type in the browser name as a raycast command parameter so that the same script can switch to any browser I want.

Parameterized default browser script command

#!/usr/bin/osascript
# Required parameters:
# @raycast.schemaVersion 1
# @raycast.title Set Default Browser
# @raycast.mode silent
# Optional parameters:
# @raycast.icon images/Safari.png
# @raycast.argument1 { "type": "text", "placeholder": "Browser Name" }
# Documentation:
# @raycast.author scott_willsey
# @raycast.authorURL https://raycast.com/scott_willsey
on run argv
# check for null or empty item 1 of argv
if (item 1 of argv) is "" then
log "You must enter a browser name."
else
set currentDefaultBrowser to my getCurrentDefaultBrowser()
set browserName to item 1 of argv
set repeatCount to 0
tell application "System Events"
try
my changeDefaultBrowser(browserName)
repeat until button 2 of window 1 of process "CoreServicesUIAgent" exists
delay 0.01
set repeatCount to repeatCount + 1
if repeatCount = 15 then exit repeat
end repeat
try
if currentDefaultBrowser contains "com.google.chrome" then
click button 1 of window 1 of process "CoreServicesUIAgent"
else
click button 2 of window 1 of process "CoreServicesUIAgent"
end if
log browserName & " is now your default browser"
on error
log browserName & " is already your default browser"
end try
on error
log "The \"defaultbrowser\" CLI tool is required: https://github.com/kerma/defaultbrowser"
end try
end tell
end if
end run
to getCurrentDefaultBrowser()
set filePath to "~/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist"
set output to do shell script "plutil -p " & filePath & " | awk '/LSHandlerRoleAll/{a=$3}/LSHandlerURLScheme/{if($3==\"\\\"https\\\"\") print a}'"
return output
end getCurrentDefaultBrowser
to changeDefaultBrowser(browserName)
do shell script "
if ! command -v /opt/homebrew/bin/defaultbrowser &> /dev/null; then
exit 1
fi
defaultbrowser " & browserName & "
exit 0
"
end changeDefaultBrowser

Footnotes

  1. Custom handler in AppleScript parlance, apparently

Default Apps December 2023

Once upon a time,1 the Hemispheric Views podcast held the first ever Duel of the Defaults in which they decided who was the winner at using the most default apps for several categories. Subsequently, Gabz posted his own default app list, and then Robb Knight took it to 11 with his App Defaults page.

In the spirit of this new fashion of blogging about one’s default apps, here are mine as of December, 2023.

NOTE: These are my personal use apps. They have nothing to do with my job at Monolith 3000, which is an all-Microsoft shop.

Official Categories

  • 📮 Mail Server
  • 📨 Mail Client
    • Apple Mail
  • 📝 Notes
  • To-Do
    • Reminders App
  • 📷 iPhone Photo Shooting
    • iOS Camera
  • 🟦 Photo Management
    • Photos App
  • 📅 Calendar
    • Apple Calendar
  • 📂 Cloud File Storage
    • iCloud Drive
  • 📖 RSS
  • 👩‍🦲 Contacts
    • Apple Contacts App
  • 🌐 Browser
    • Safari, Chrome for web work
  • 💬 Chat
    • Messages, Signal, Discord, Slack
  • 🔖 Bookmarks
  • 📑 Read It Later
  • 📜 Word Processing
    • Pages
    • Google Docs
    • Microsoft Word
  • 📈 Spreadsheets
    • Excel (I’m sorry, but Numbers is the most useless excuse for a spreadsheet app I’ve ever seen)
  • 📊 Presentations
    • Keynote
  • 🛒 Shopping List
    • Reminders
  • 🍴 Meal Planning
  • 💰 Budgeting and Personal Finance
    • Spreadsheet
  • 📰 News
    • RSS
    • Mastodon
    • Apple News
  • 🎵 Music
    • Apple Music
  • 🎤 Podcasts
  • 🔐 Password Management

Additional Categories

Footnotes

  1. November 2nd, 2023, in fact

Warp Blocks

Part of the Warp series

I like Warp. I did not intend to like Warp. I didn’t even want to like Warp. But after giving it a fair shot for a few days, I couldn’t help but liking it and sticking with it. It just feels to me like everything a next generation terminal should be.

According to its developers,

Warp is a modern, Rust-based terminal with AI built in so you and your team can build great software faster.

That sounds nice, if not a bit vague. What is so great about Warp, and what makes it more desirable to use than macOS Terminal app or iTerm2 or Secure Shellfish, for example? The obvious answer is its features and the workflows they enable. One of my favorite of those features is Blocks.

Blocks

Blocks are one of the biggest Warp features, in my opinion, and one that I love and take advantage of all the time. Blocks are simply a grouping of a command and its output into one selectable and actionable section in Warp. For example, here’s the result of a ps -aux command that is now a block.

Warp Block for a process list command

My new waiting command prompt is below the block and is now in a separate block from it, but I can scroll up and down to see the whole ps -aux block and perform actions on it. Blocks are nice because not only do they negate having to use something like ps -aux | more to see the full output of a command, but they also remain selectable, filterable, and modifiable.

Speaking of filterable… here’s that same block filtered by the word “python”. Look how small it is… down from 124 lines of output to 3!

Filtering can accept regular expressions or case-sensitive text simply by choosing those options in the filter search, just like most modern text editors. The keyboard shortcut for filtering is Shift-Option-F (⇧ ⌥ F).

You may be thinking, hang on… I can just pipe the command to grep like this: ps -aux | grep 'python', and you’re right. You can. But filtering is fast and you can further act on it by saving the filtered output or the filtered output and the command, and more.

Saving the filtered output of a Block

Update 2023-12-17

Warp has a great blog post about Block filtering vs. grep that help answer the obvious question of why use block filtering instead of just using grep: New utilities for your terminal, inspired by text editors | Warp

Bookmarking blocks is nice too. For example, here I left the previous ps -aux block filtered for “python” and bookmarked it. I then executed a bunch of other commands which all created their own blocks and the bookmarked ps -aux block scrolled off the screen. But by hitting Option-up (⌥-↑), I immediately jumped back up to the bookmarked (and still filtered) block.

Jumping to a bookmarked Block

Filtering isn’t the only way to find things in a block, you can also use standard old Command-F (⌘-F) to search within a block. The difference between this and filtering is exactly what the words imply: filtering reduces the output of the command to just lines with matches, while search finds search text or regular expression matches in the command output, but doesn’t filter to only show matching lines.

Searching in a Block

One nice feature is using search in a “live” block, like one that’s running an htop or a tail -f command. As the output of the running process updates, so do your search results.

There’s more to Warp Blocks than what I’ve mentioned here. Check out Warp’s documentation on Blocks to see for yourself.

Astro 3.3 Picture Component

Part of the Astro series

The last time I wrote about image optimization and responsive images in Astro, there were some compromises to be made in order to use astro:assets image optimization.

First, it did not resize images or create responsive source sets, it only optimized the format and file byte size with the same width and height. Second, it did and still does require importing images in a way that at the very least requires you to know the file extension of the original, and in a way that I thought made dynamically creating image source file names impossible. And finally, there was no control at all over how images referenced in markdown are optimized.

The good news is that as of Astro 3.3, there is an improved astro:assets Image component as well as a new astro:assets Picture component that solve a lot of my problems. The issue of leaving it up to Astro to do what it wants with markdown images still exists, but I have a compromise for that issue.1 In the meantime, Astro 3.3+ can now create multiple image formats, multiple image pixel sizes, and can create both img and picture tags with source sets for great image responsiveness.

A Problem Requiring Responsive Images and Dynamic Image Names

Let’s look at a real world use case to show you a typical scenario where you need to create your image source file names dynamically as well as create multiple sizes.

Friends with Brews is a podcast about conversations and different kinds of brewed drinks – beer, coffee, or tea. Because part of the show’s premise is trying new drinks and giving them a thumbs-up or thumbs-down, I also want to keep a list of these drinks and showcase them both on the individual episodes they’re consumed on as well as on an aggregate brews page.

Friends with Brews brew list page

Each of these little detail blocks links to a details page for the specific drink.

Drink details page

Because several of these images are typically displayed on any given page, or because the image on the individual drink detail pages are dimensionally larger, I want them optimized as much as possible. In addition, I want them to be as responsive as possible for different size screens and different pixel densities.

One thing to note is that all of the drink details for every drink are kept in a JSON file called brews.json. This JSON file includes the name of the image associated with the drink. The JSON entry for a specific brew looks like this:

{
"id": "YodfV6yTeB75PJpmQDPgU",
"name": "Oktoberfest Märzen",
"brewery": "Hacker-Pschorr",
"image": "HackerPschorrOktoberfestMarzen-8E6221D0-7EB7-46B3-8AFA-5CA4A1E6AC1B",
"description": "As it was forbidden to brew in summer, a stronger beer -- the Märzen -- was brewed earlier in March. It would finally be served at the Oktoberfest, under the \"Heaven of Bavaria.\" We have returned once again to the age-old recipe and recreated that gloriously smooth, honey-coloured piece of history from times gone by. Perfect with: Bavarian sausage salad and all the titbits a true Bavarian would also enjoy: roast chicken, sausages or suckling pig cooked in Wiesn beer.",
"type": "beer",
"sortOrder": "0",
"episodes": ["54"],
"url": "https://www.hacker-pschorr.com/our-beers/usa/oktoberfest-maerzen",
"rating": [
{
"host": "Peter",
"vote": "thumbs-up",
"description": "It is malty, it is toasty, and it is crisp. Very happy, this is good."
}
]
},

Note the image entry: the image name doesn’t have a file extension for technical reasons, namely that when you’re importing images in an Astro file, you can’t create a dynamic file name including the file extension. Vite has to know the file extension ahead of time, even if you do cobble together the rest of the file name at compile time.

Because of this, I just made all my images PNG, regardless of what they would later be optimized to by the various image optimization components I’ve used over the life of the Friends with Brews website, and therefore I also didn’t include the file extension because I would be hard-coding it into the image name string to keep Vite happy.

It sounds more confusing than it is. Here’s my code pre-Astro 3.0, back when Astro had an image optimization tool called @astrojs/image which had both Image and Picture components. Note the src property of the Picture component and the above paragraph will make more sense.

---
import { Icon } from "astro-icon/components";
import { Picture } from "@astrojs/image/components";
const { brew, episode } = Astro.props;
---
<div class="brew">
<div class="brew-image">
<a href={`/bottle/${brew.id}/`}>
<Picture
src={`/images/brews/${brew.image}.png`}
widths={[400, 800, 1200]}
aspectRatio="1:1"
sizes="200px"
formats={["avif", "webp", "png"]}
alt={`${brew.brewery} ${brew.name}`}
/>
</a>
</div>

Astro 3.0 - 3.2.x Solution

After migrating to Astro 3 and before Astro 3.3 gave the ability to generate multiple widths for each desired image format, I was generating those myself. For my brew images, I was creating 400 pixel wide and 1000 pixel wide images for each drink in addition to the original size. This was all automated with Retrobatch, but it was still a little more work and management on my part.

Retrobatch automated image generation

In my case, I would generate the 400 and 1000 pixel wide images in webp, and also keep the full-sized png on hand to link to for people who like staring at large images with equally impressive download times.

Here’s what my BrewsDetails.astro file looked like with this solution:

---
import { Icon } from "astro-icon/components";
import { capitalize, brewIcon } from "./utilities/stringformatter.mjs";
const { brew, episode } = Astro.props;
const brewImage = brew.image.replace(/(-)(\w{8}-\w{4}-\w{4}-\w{4}-\w{12})$/, "-400$1$2") + ".webp";
---
<div class="brew">
<div class="brew-image">
<a href={`/bottle/${brew.id}/`}>
<img src={`/images/brews/${brewImage}`} alt={brew.name} />
</a>
</div>
<!-- Etc, etc, etc -->

The first thing eagle-eyed readers will notice is that I’m not using any kind of image component at all here – just a plain old html img tag. This is because I was already generating the optimized images and because I was using a wider image size than needed to help with the high pixel-density display cases.

This string is just making sure we’re grabbing the 400 pixel wide version of the image for this view:

const brewImage = brew.image.replace(/(-)(\w{8}-\w{4}-\w{4}-\w{4}-\w{12})$/, "-400$1$2") + ".webp";

This is all fine and dandy, but it’s less optimal than the pre-Astro 3.0 days, because the html I was using does nothing for lazy loading, does nothing for only grabbing the largest image needed for the device screen size and pixel density, and is basically a massive compromise.

I was not a giant fan of having to do this, in other words.

Fortunately, the smart people at Astro kept refining Astro 3.x’s image optimization solution.

Astro 3.3+ Solution

Now that Astro 3.3+ has the ability to use the Image or Picture components to generate multiple widths per image format, this solves the problem of different screen sizes and pixel densities. Now I can let it generate the extra images for me.

Here’s what the Picture component looks like for these small drink images:

---
import { Icon } from "astro-icon/components";
import { Picture } from "astro:assets";
import { capitalize, brewIcon } from "./utilities/stringformatter.mjs";
const { brew, episode } = Astro.props;
---
<div class="brew">
<div class="brew-image">
<a href={`/bottle/${brew.id}/`}>
<Picture src={import(`../assets/images/brews/${brew.image}.png`)}
formats={['avif', 'webp', 'jpg']}
width="400"
widths={[400, 800]}
alt={`${brew.brewery} ${brew.name}`} />
</a>
</div>
<!-- Etc, etc, etc -->

Here’s the file diff between my Astro 3.0-3.2 version versus my new Astro 3.3+ version:

BrewsDetails.astro file diff

By the way, remind me to talk about Kaleidoscope sometime. Its ability to diff things in multiple views, act as the command line difftool, and show diffs between different git commit versions is really spectacular.

Importing images using dynamic file names

Notice the difference between the way the Picture src property is composed here compared to the pre-Astro 3 version in the first half of this article? This is because the images located in the src directory need to be imported for the astro:assets Image and Picture components. Typically import statements do not like dynamically constructed strings – they error out and complain that you need to give them a static string.

Fortunately I ran across an Astro Discord thread explaining how to do the import using a dynamic string (still requiring the file extension to be statically defined, of course), and let’s just melodramatically say that it changed my life.

Another equivalent way to do it would be to use import.meta.glob to grab all the image assets and then grab the image using the key of the matching image file name, like this:

---
const images = import.meta.glob("../../assets/images/brews/*.png");
---
<Picture src={images[`../../assets/images/brews/${bottle.image}.png`]()} formats={['avif', 'webp']} alt="A description." />`

If, like me, you were desperately hoping for a better way to optimize your images after migrating to Astro 3, your prayers are pretty much answered. There’s still the issue of image links in your markdown files, though. I’ll cover that topic in a future post.

Resources

Astro 3.3: Picture component | Astro Blog
Images Component 🚀 Astro Documentation
Picture Component 🚀 Astro Documentation
My Responsive Images series

Footnotes

  1. The issue of how markdown images are optimized is not completely immaterial because markdown image links are used for every single image inside blog posts or podcast show notes, for example.