sc
Scott avatar
ttwillsey

Remarking the Socials

Astro

Part of the Astro series

Contents

Astro Remark Support

One of the cool things about Astro is how it supports Markdown using remark. This means it also supports remark plugins, and THAT means you can write your own custom remark plugins to modify the markdown in your posts however you like.

Astro’s documentation has many examples of modifying front matter with remark. Actually modifying things in the markdown content itself is a slightly different matter, but it’s still pretty simple, all things considered. Astro has a recipes and guides section on their Community Educational Content page (basically links to external articles), and in that recipes and guides section is a section on Markdown, with a link to this example:

Remove runts from your Markdown with ~15 lines of code · John Eatmon

I don’t care about runts because I’m neither a pig farmer nor a person who notices them on my own blog. But I’m glad John cares, because he basically outlined a strategy for looking for and transforming specific things in my blog posts.

If you read a lot of blogs, you’ll notice that most times you see social media or YouTube videos linked to, they’re basically a fancy little mini-view of the content called an embed – the content is actually embedded into the post rather than just being a link.

Naturally I want that look for any social media or YouTube links I post here, but one constant with me is that I never like to know implementation details to write a post. That includes things like embedding links from YouTube, Mastodon, Threads, or whatever. I want to be able to just paste the link in and have my site handle it for me. There is an astro integration called Astro Embed that will worry about this for you, but it doesn’t support Mastodon or Threads links. So I created my own remark plugin that does, primarily because I found it easier than modifying the Astro Embed extension.

Mastodon links are weird compared to other social network links in that they don’t have a known common domain for every link. There are all sorts of Mastodon URLs out there. My profile link, for example, is https://social.lol/@scottwillsey. Take that, X. YouTube links are easy, and Threads links are easy. It’s trivial to use regular expressions to find these links, assuming they exist on a line all by themselves, unadorned and glaringly obvious, like a hanging chad desperately waiting to be peered at and analyzed within an inch of its life.1

Step 1 in transforming the social links is creating aforementioned regular expressions and testing them.

If you have a Mac and you do any scripting or text file management or log analysis, I highly suggest BBEdit from Bare Bones Software. It’s not cheap, it’s complex, and a lot of things are done in counterintuitive ways. But it’s powerful, and it has an outstanding Pattern Playground feature for building and testing regular expressions. It’s simple to make a bunch of sample posts and try matches and replacements on them to craft both your regular expressions and the replacement strings for the embed code.

BBEdit Pattern Playground

Here are the regular expressions I’m currently using for Mastodon, Threads, and YouTube, respectively.

Mastodon regex
const mastodonRegex =
/^https:\/\/([a-zA-Z0-9.-]+)\/(@[\w-]+\/\d{10,20})$/;
Threads regex
const threadsRegex =
/^https:\/\/www\.threads\.net\/(@[\w.]+)\/post\/([A-Za-z0-9_\-]+)(\?.*)?$/;
YouTube regex
const youtubeRegex =
/^https:\/\/(?:www\.youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)(?:\S*)?$/;

These may change as I encounter variations of the different URLs for each service. These are rev 2 of the Threads and YouTube regular expressions, for example.

How Remark Plugins Work in Astro

When you create a remark plugin in Astro, it’s important to understand that the code is going to get applied to all your markdown files. So for whatever you see in your remark function, that will attempt to apply to every single post and any other pages you have where the actual content is inside a markdown file. That concept is important, because it makes it clearer what’s happening when you look at an actual remark plugin.

Creating a Remark Plugin in Astro

Creating a remark plugin in Astro is pretty simple. Somewhere in a folder you like under src, create a .mjs file with a name you like, such as remark-plugins.mjs. Inside that file, export a function:

remark-plugins.mjs
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();
};
}

Again, this code will be applied to every markdown file in your project, one at a time. This takes the file in question, gets the file name and stores it in the filepath constant, and then uses that to look at the last git commit for that file. Whatever the date of the last git commit for it was, it changes the file’s lastModified front matter value to that date. Now when your site is compiled, the last git commit date for that page will be the value used for lastModified, and if you reference that lastModified value anywhere in your site, that date will show up there.

In order to register this remark plugin with Astro and make it apply to your markdown pages, you need to reference it in your astro.config.mjs file like this (note the highlighted lines):

astro.config.mjs
import { defineConfig } from "astro/config";
import expressiveCode from "astro-expressive-code";
import pagefind from "astro-pagefind";
import { rehypeAccessibleEmojis } from "rehype-accessible-emojis";
import remarkToc from "remark-toc";
import { remarkModifiedTime } from "./src/components/utilities/remark-modified-time.mjs";
import { remarkSocialLinks } from "./src/components/utilities/remark-social-links.mjs";
/** @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), pagefind()],
markdown: {
remarkPlugins: [
[remarkToc, { heading: "contents" }],
remarkSocialLinks,
remarkModifiedTime,
],
rehypePlugins: [rehypeAccessibleEmojis],
},
});

Remarking Markdown Page Content

Changing the markdown in the body of the markdown file is a little different. It’s possible that it can be done directly, but to the best of my knowledge, it requires walking the DOM tree of the document and looking at each node. This will allow us to look at the solo lines of text containing our social media URLs individually. To do this, we use a package called unist-util-visit.

Here’s the bones of the plugin we’ll create:

remark-social-links.mjs
import { visit } from "unist-util-visit";
export function remarkSocialLinks() {
return (tree) => {
visit(tree, "text", (node) => {
// do things on each node, or line of text in the markdown file
});
};
}

For each line, we’ll check it against our regular expressions and perform the appropriate action (replace the bare URL with whatever embed code is appropriate for the link).

remark-social-links.mjs
import { visit } from "unist-util-visit";
export function remarkSocialLinks() {
return (tree) => {
visit(tree, "text", (node) => {
let matches;
if ((matches = node.value.match(youtubeRegex))) {
const videoId = matches[1];
node.type = "html";
node.value = replacementTemplates.youtube(videoId);
} else if ((matches = node.value.match(mastodonRegex))) {
const domain = matches[1],
id = matches[2];
node.type = "html";
node.value = replacementTemplates.mastodon(domain, id);
} else if ((matches = node.value.match(threadsRegex))) {
const user = matches[1],
id = matches[2];
node.type = "html";
node.value = replacementTemplates.threads(user, id);
}
});
};
}

That’s great… but you may have noticed that there are no actual definitions for youtubeRegex, mastodonRegex, threadsRegex, or any of their replacement templates in the above function.

Well, earlier I showed you my regular expressions. I didn’t show you the replacement strings, but here’s the whole thing, including regular expressions (highlighted) and replacement strings (also highlighted):

remark-social-links.mjs
import { visit } from "unist-util-visit";
export function remarkSocialLinks() {
return (tree) => {
visit(tree, "text", (node) => {
const youtubeRegex =
/^https:\/\/(?:www\.youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)(?:\S*)?$/;
const mastodonRegex =
/^https:\/\/([a-zA-Z0-9.-]+)\/(@[\w-]+\/\d{10,20})$/;
const threadsRegex =
/^https:\/\/www\.threads\.net\/(@[\w.]+)\/post\/([A-Za-z0-9_\-]+)(\?.*)?$/;
const replacementTemplates = {
youtube: (id) =>
`<iframe width="560" height="400" src="https://www.youtube.com/embed/${id}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>`,
mastodon: (domain, id) =>
`<iframe src="https://${domain}/${id}/embed" class="mastodon-embed" style="max-width: 100%; border: 0" width="400" allowfullscreen="allowfullscreen"></iframe><script src="https://${domain}/embed.js" async="async"></script>`,
threads: (user, id) =>
`<div class="threads-post">
<blockquote class="text-post-media" data-text-post-permalink="https://www.threads.net/${user}/post/${id}" data-text-post-version="0" id="ig-tp-${id}" style=" background:#FFF; border-width: 1px; border-style: solid; border-color: #00000026; border-radius: 16px; max-width:800px; margin: 1px; min-width:270px; padding:0; width:99.375%; width:-webkit-calc(100% - 2px); width:calc(100% - 2px);"> <a href="https://www.threads.net/${user}/post/${id}" style=" background:#FFFFFF; line-height:0; padding:0 0; text-align:center; text-decoration:none; width:100%; font-family: -apple-system, BlinkMacSystemFont, sans-serif;" target="_blank"> <div style=" padding: 40px; display: flex; flex-direction: column; align-items: center;"><div style=" display:block; height:32px; width:32px; padding-bottom:20px;"> <!--missing svg here--> </div> <div style=" font-size: 15px; line-height: 21px; color: #999999; font-weight: 400; padding-bottom: 4px; "> Post by ${user}</div> <div style=" font-size: 15px; line-height: 21px; color: #000000; font-weight: 600; "> View on Threads</div></div></a></blockquote>
<script async src="https://www.threads.net/embed.js"></script>
</div>`,
};
let matches;
if ((matches = node.value.match(youtubeRegex))) {
const videoId = matches[1];
node.type = "html";
node.value = replacementTemplates.youtube(videoId);
} else if ((matches = node.value.match(mastodonRegex))) {
const domain = matches[1],
id = matches[2];
node.type = "html";
node.value = replacementTemplates.mastodon(domain, id);
} else if ((matches = node.value.match(threadsRegex))) {
const user = matches[1],
id = matches[2];
node.type = "html";
node.value = replacementTemplates.threads(user, id);
}
});
};
}

You can see that replacementTemplates is a javascript object that contains three functions. Each of those functions returns the text create by the string literals inside of them. These string literals are the embed template with appropriate insertion of the specific unique information in the URL, such as username, post or video ID, or domain name (in the case of Mastodon).

That’s my entire remark plugin. I register it in astro.config.mjs and it gets executed upon all my blog posts automatically.

astro.config.mjs
import { defineConfig } from "astro/config";
import expressiveCode from "astro-expressive-code";
import pagefind from "astro-pagefind";
import { rehypeAccessibleEmojis } from "rehype-accessible-emojis";
import remarkToc from "remark-toc";
import { remarkSocialLinks } from "./src/components/utilities/remark-social-links.mjs";
/** @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), pagefind()],
markdown: {
remarkPlugins: [[remarkToc, { heading: "contents" }], remarkSocialLinks],
rehypePlugins: [rehypeAccessibleEmojis],
},
});

Summarium

That’s how easy it is to programmatically modify content in a markdown file in Astro.

It’s probable that I can walk the tree without using unist-util-visit, based on the Astro documentation remark plugin example called Add reading time, so I’ll probably make that modification. Maybe I can condense my check/replacement code a little more too.

Footnotes

  1. Remember when hanging chads were the biggest of our political problems? It can definitely be argued, however, that there’s a direct line from those hanging chads to where we are today with people storming the capitol to protest a “stolen election”.

I Do It for Myself

This morning as I was going through my blogroll waiting for my double-height cup of coffee to kick in, I came across Cole’s post about the obvious rewards, or lack thereof, of blogging (or any content creation, really), and it resonated with me. Why DO people like me have websites that we update and maintain and post links to when it genuinely seems like maybe one or two people at most ever notice?

The good thing is that we can do our art because we love it. It really doesn’t matter how great (or small) the response is. As Robert notes, “I’m doing this because I love it. If others like it too, great, but that’s not the main purpose.”

That’s it exactly. It’s not about public notoriety or the dopamine hit of notifications and increasing follower counts. I don’t care at all about those things. I care about doing things I find interesting, and if someone ever finds one of my posts useful, that will be wonderful! That’s what I want! But it’s not what I need in order to keep doing it.

If you’re going to make a business or a large part of your income from your online work, you need eyeballs, and you need “engagement” (I really don’t like that word). But when I see some of my friends obsessing about their follower counts and using the word engagement non-ironically, I just think that I never want to have to live that way. 😄 I’m one of those dumb enthusiasts who doesn’t track anything, has almost no followers, and loves the fact that it’s never going to be about numbers.

But what about you? What are you doing with your website to make it uniquely yours? I like to link to people’s stuff on my links page, whether it be in my Blogroll section or my Cool Site Spotlight. The best of the web is people doing things because they love it, and linking to each other. Let me know on Mastodon what I’m missing!

Linked post: I do it for myself

Redo, Redoes, Redid

You may have noticed that today marks a redesign that I hope brings a cleaner, sleeker, easier to read format to the site. I’m kind of excited about it – I hope it’s at least tolerable for you, the reader! Even better, I hope you actually like it.

This site has had a lot of redesigns over the years. The worst were during the Wordpress years. The site started getting good1 during the Hugo and Eleventy years, and my satisfaction with it has only increased in the current Astro incarnation.2

I certainly like this version better than yesterday’s site, which had a too-large site title and darker backgrounds for post content. I used this design to give posts delineation in the index page list view, but it really just made things feel cramped and busy.

For reference, here’s what it looked like on June 21, 2024.

HomePage20240619

And here’s the redesign I launched today, June 22, 2024.

HomePageUpdate20240620

And the same in light theme:

HomePageUpdateLightTheme20240620

An obvious difference is I’ve reverted back to a horizontal header navigation menu instead of the sticky vertical side menu. Also I’ve reverted to having icons only, without text, in the navigation menu. And finally, they’re colored icons! I had colors in my menu icons back in 2021, and I kind of missed it.

Here’s what part of a blog post page looked like yesterday, followed by an image of what that same blog post page looks like today.

ScottWillseyPost20240619

ScotWillseyPost20240620

As much as side menus are nice, I do like the centered, slightly wider blog post view that the header menu approach affords.

And just for fun, here’s a shot from 2020, showing what things looked like then. Not long after this, I added color to the menu icons, but I haven’t found a screenshot of that yet. I’m pretty sure I have one somewhere.

ScottWillsey2020919

I still have some tweaks and fixes to make, but nothing too breaking. I hope you enjoy the new look!

Footnotes

  1. “Good” is a relative term because I am, after all, the one designing my web site. I have some design skill limitations to be sure.

  2. Slight aside: Astro is by far the “best” (by my definition of the word) site framework I’ve used to date. It allows for static (pre-rendered) or SSR (on-demand rendered) modes, eschews templating languages like Mustache and Handlebars, and is incredibly flexible.

Astro Sitemap Page Modified Timestamps

Astro

Part of the Astro series

Threads generally makes me sad by being social media, so I don’t look at it often. Imagine my surprise today when I was wading through some hostile replies to my thoughts about an F1 related topic and I stumbled across a comment to me about my post on Using Git Hooks for Displaying Last Modified Dates and how to apply it to Astro Sitemap.

Post by @zaitovalisher
View on Threads

Interesting question. I guess the approach would be to first use pre-commit to modify the timestamps on any pages updated in the commit and then modify the sitemap file by looking for any pages with date modified timestamps and updating their entries in the sitemap.

There is a serialize function in Astro Sitemap and it looks like it happens on build when writing out the sitemap. If this is true, so long as you do your git commit before you do your build, it should update the pages with the correct last modified dates.

Now I’m going to have to play with Astro Sitemap and find out!

Scrolling Screenshots in CleanShot X

Mac

Part of the Mac series

I wrote about OCR in CleanShot X in my last post, and my friend David Nelson reminded me of another stellar feature of CleanShot X – scrolling screenshots.

It’s true, this is a great feature. It’s a little counterintuitive to get it to work initially, but once you get it, you’ll use this all the time. I have a keyboard shortcut set up to initiate a scrolling screenshot, but you can do it from the CleanShot X menubar icon (or even from Raycast – more on that later). All I have to do is hit ⇧⌥⌘4 to start the scrolling capture using CleanShot X. Then it’s a little odd - it wants you to drag an ouline around the area to be scrolled. Usually this means my full browser window. Then click Start Capture, click Auto-Scroll, click Done when it finishes, and then you have a long screenshot.

Here’s the result.

Pro Publica Screenshot

I originally put a screenshot of my own site’s home page here, but It looks a little funny because I have a site menu that doesn’t disappear up the page as it scrolls, so the menu looks long and repetitive in a way that it isn’t. Page scrollbars have similar issues, but overall, it’s a great feature that’s useful if you have a need to document a long document of any kind.

OCR in CleanShot X

Mac

Part of the Mac series

I love this modern era of computing, and do you know why? Text Recognition, also known as OCR in many apps, is amazing in so many apps and OSes now, and it is very useful.

I’ve written about Image Search Text Recognition in Raycast and ScreenFloat before. Another Mac app that has Text Recognition using OCR technology (according to its own website) is CleanShot X.

CleanShot X Text Recognition

CleanShot X does Text Recognition a little differently than other apps. With CleanShot X, you can use a keyboard shortcut to bring up a capture tool, exactly like a screenshot capture tool, and you drag over the area you want text recognition in. In my case, I have ⌥⇧⌘O set as my keyboard shortcut.

CleanShot X OCR Keyboard Shortcuts

Let’s say I have a screenshot of the title of The Verge’s recent iPad Pro article, for some inexplicable reason.

The Verge Screen Shot

I can hit ⌥⇧⌘O, drag over the part of the image with the text I want to capture, and release. CleanShot X automatically detects all text in that region and copies it to the clipboard. Then I can (also inexplicably) open TextEdit and paste it in.

CleanShot X The Verge OCR Results

I like the simplicity of it and the fact that I get to define the region to look for text in, and the fact that it just copies all text in that region to the clipboard without me having to pick and choose words or lines or paragraphs or whatever.

Here’s a little tip for you Windows users: Snipping Tool has Text Recognition built in too. You can fire it up and capture onscreen notes that people are typing in Teams meetings and use the Text Recognition tool to grab the text for yourself in case the presenter forgets to send out a particular set of notes they’re typing up as they’re talking. It’s great. I do it all the time.

There are lots of Text Recognition examples in macOS and iOS and apps that run on those platforms, and I celebrate them all. We live in a golden age of utility software.

ChatGPT for macOS Will Not Sherlock Raycast AI for Me

Raycast

Part of the Raycast series

I’ve finally had a chance to play with the ChatGPT macOS app1 and I’m here to say it doesn’t swing the uppercut required to get me to stop paying for Raycast Advanced AI. Right now the one thing it has that Raycast AI does not is the ability to upload files for parsing, but that’s coming soon to Raycast AI. Raycast also keeps playing with ideas like support for local LLMs to augment their Advanced AI plan support for models like OpenAI GPT-4o, Anthropic Claude 3 Sonnet and Opus, and Perplexity Llama 3 Sonar Large.

Raycast local LLM prototype demo

Add to that things like Raycast AI Commands, which I posted about previously, and Raycast AI is still a very attractive option for integrating AI into workflows where it makes sense to do so. I feel like I have to add that caveat given that a lot of people want to dismiss the whole thing out of hand as some kind of scam. It’s not – but that doesn’t mean LLMs are applied optimally in a lot of cases and it doesn’t mean I trust the companies involved to take time to come up with correct and optimum use cases.2

Slight tangent – I think I view Raycast AI Commands as similar in purpose to things like Fabric and GPTScript, even if different in scope and flexibility, possibly. Definitely more on that as I find time to investigate all of these further.

Footnotes

  1. ChatGPT Plus subscription required

  2. By the way, keep an eye on Pragmatic podcast for an upcoming episode on this very topic.