
2 min read
Back Up Your Site With a Git Branch
Before deleting my entire site for the rebuild, I made a one-command backup that keeps the old code browsable forever. Here's the five-minute git trick.
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(reference('tags')).default([]),
draft: z.boolean().default(false),
summary: z.string().optional(),
related: z.array(reference('blog')).default([]),
}),
});
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;
// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);
export const person = buildPiece({
'@id': ids.person,
'@type': 'Person',
name: AUTHOR.name,
url: `${SITE.url}/`,
jobTitle: AUTHOR.occupation,
sameAs: Object.values(SOCIALS),
});
export const postUrl = (post: Post) =>
`${SITE.url}/blog/${post.id}/`;export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection('blog', ({ data }) =>
import.meta.env.DEV ? true : !data.draft,
);
return posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
}
/** Whole minutes at ~200 words per minute, never less than 1. */
export function readingTime(post: Post): number {
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}// Apply the theme before first paint, and again after every
// ClientRouter swap (the new document arrives without the class).
const apply = () => {
const stored = localStorage.getItem('theme');
const dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
};
apply();
document.addEventListener('astro:after-swap', apply);Digital Duct Tape @ Plantonix
I run web and marketing operations for an Amazon and Shopify based business, blog about iPhone and Mac apps, and build fun things with iOS Shortcuts.


A soil mix calculator for garden beds. Enter your bed size and get the right amounts of coco coir, perlite, vermiculite, and worm castings, with preset and custom ratios.

A tool that allocates quart sized units from bulk totes, with live sliders and real time math. Built to speed up bulk product planning at my day job.

Automations for iPhone and Mac built with the Shortcuts app: utilities, toolkits, and experiments published on RoutineHub.

· 2 min read
Before deleting my entire site for the rebuild, I made a one-command backup that keeps the old code browsable forever. Here's the five-minute git trick.

· 4 min read
I tore this entire site down to the studs and rebuilt it with Astro 7, with an AI doing the heavy lifting. Here's the story, and the start of a series.

· 3 min read
I recently started co-hosting a podcast for RoutineHub, check out Episode 5!

· 3 min read
Use macOS Shortcuts to control any app that has menus, buttons, or accepts keyboard input.

· 3 min read
Scammers team up to pretend they're your bank.