Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
464 changes: 463 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
"astro": "^5.0.0"
},
"devDependencies": {
"@resvg/resvg-js": "^2.6.2",
"remark-breaks": "^4.0.0",
"sass": "^1.79.0"
"sass": "^1.79.0",
"satori": "^0.33.4"
}
}
Binary file removed public/assets/background.jpg
Binary file not shown.
10 changes: 5 additions & 5 deletions src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@ import '../styles/index.scss';
interface Props {
title?: string;
description?: string;
images?: { path: string; alt: string }[];
isPost?: boolean;
slug?: string;
}

const { title, description, images, isPost = false } = Astro.props;
const { title, description, isPost = false, slug } = Astro.props;

const metaUrl = new URL(Astro.url.pathname, config.domain).href;
const metaTitle = isPost && title ? `${title} · ${config.title}` : config.title;
const metaDescription = (isPost && description) || config.description;
const metaImagePath = images?.length ? images[images.length - 1].path : '/assets/background.jpg';
const metaImage = new URL(metaImagePath, config.domain).href;
// Every page gets a generated ledger card (see pages/og/[slug].png.ts)
const metaImage = new URL(`/og/${slug ?? 'default'}.png`, config.domain).href;
---

<!doctype html>
Expand All @@ -30,7 +30,7 @@ const metaImage = new URL(metaImagePath, config.domain).href;
<meta property="og:title" content={metaTitle} />
<meta property="og:description" content={metaDescription} />
<meta property="og:image" content={metaImage} />
<meta name="twitter:card" content="summary" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@joshnesbitt" />
<meta name="twitter:creator" content="@joshnesbitt" />
<meta name="twitter:url" content={metaUrl} />
Expand Down
190 changes: 190 additions & 0 deletions src/lib/og.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import fs from 'node:fs';
import path from 'node:path';
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import type { CollectionEntry } from 'astro:content';
import type { TableNode } from '../content.config';
import config from '../config.json';

// The Kitchen Ledger palette (mirrors src/styles/_settings.colors.scss)
const colors = {
bg: '#fbfbf8',
ink: '#24292c',
accent: '#0e7490',
tint: '#eef4f4',
ruleStrong: '#bcc1b9',
muted: '#7c8380',
food: '#6b6f4a',
foodTint: '#f0f1e2',
};

const serif = 'Source Serif 4';
const mono = 'IBM Plex Mono';

type Style = Record<string, unknown>;
type Node = { type: string; props: { style: Style; children?: unknown } };

// Satori misreads an empty children array as multiple children, so omit it
const h = (type: string, style: Style, ...children: unknown[]): Node => ({
type,
props: {
style,
children: children.length === 0 ? undefined : children.length === 1 ? children[0] : children,
},
});

const font = (file: string) =>
fs.readFileSync(path.join(process.cwd(), 'src/og/fonts', file));

const fonts = [
{ name: serif, data: font('SourceSerif4-Regular.ttf'), weight: 400 as const },
{ name: serif, data: font('SourceSerif4-SemiBold.ttf'), weight: 600 as const },
{ name: mono, data: font('IBMPlexMono-Regular.ttf'), weight: 400 as const },
{ name: mono, data: font('IBMPlexMono-SemiBold.ttf'), weight: 600 as const },
];

const monoLabel: Style = {
fontFamily: mono,
fontSize: '17px',
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
};

const leafCount = (node: TableNode): number =>
typeof node === 'string' ? 1 : node.from.reduce((sum, child) => sum + leafCount(child), 0);

// Long titles step down so they never wrap past two lines
const titleSize = (title: string) =>
title.length <= 16 ? 104 : title.length <= 26 ? 88 : title.length <= 36 ? 72 : 60;

function recipeCard(post: CollectionEntry<'posts'>, entryNo: number): Node {
const isDrink = post.data.type === 'drink';
const ingredients = post.data.table ? leafCount(post.data.table) : null;
const logged = post.data.date.toISOString().slice(0, 7);
const facts = [ingredients && `${ingredients} ingredients`, `Logged ${logged}`]
.filter(Boolean)
.join(' · ');

return h(
'div',
{
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
padding: '64px 72px',
backgroundColor: colors.bg,
color: colors.ink,
fontFamily: serif,
},
h(
'div',
{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
paddingBottom: '20px',
borderBottom: `2px solid ${colors.ink}`,
...monoLabel,
},
h('span', { color: colors.accent }, config.title),
h('span', { color: colors.muted }, `No. ${String(entryNo).padStart(2, '0')}`)
),
h(
'div',
{ display: 'flex', flexDirection: 'column', justifyContent: 'center', flexGrow: 1 },
h(
'div',
{
fontSize: `${titleSize(post.data.title)}px`,
fontWeight: 600,
lineHeight: 1.02,
letterSpacing: '-0.01em',
marginBottom: '30px',
},
post.data.title
),
post.data.description
? h(
'div',
{ fontSize: '32px', lineHeight: 1.35, color: colors.muted, maxWidth: '760px' },
post.data.description
)
: null
),
h(
'div',
{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: '22px',
borderTop: `1px solid ${colors.ruleStrong}`,
color: colors.muted,
...monoLabel,
},
h(
'div',
{ display: 'flex', alignItems: 'center' },
h(
'span',
{
fontSize: '15px',
color: isDrink ? colors.accent : colors.food,
backgroundColor: isDrink ? colors.tint : colors.foodTint,
border: `1px solid ${isDrink ? colors.accent : colors.food}`,
borderRadius: '3px',
padding: '4px 12px',
marginRight: '18px',
},
isDrink ? 'drink' : 'food'
),
h('span', {}, facts)
),
h('span', { color: colors.accent }, new URL(config.domain).hostname)
)
);
}

function defaultCard(entryCount: number, firstYear: number): Node {
return h(
'div',
{
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.bg,
color: colors.ink,
fontFamily: serif,
border: `14px solid ${colors.tint}`,
},
h('div', { fontSize: '96px', fontWeight: 600, letterSpacing: '-0.01em' }, config.title),
h(
'div',
{ fontSize: '30px', color: colors.muted, marginTop: '10px' },
`Findings from the kitchen, ${firstYear} → present. ${entryCount} entries.`
),
h('div', {
width: '120px',
borderTop: `2px solid ${colors.accent}`,
margin: '36px 0',
}),
h(
'span',
{ ...monoLabel, fontSize: '19px', letterSpacing: '0.1em', color: colors.accent },
new URL(config.domain).hostname
)
);
}

async function renderPng(node: Node): Promise<Uint8Array> {
const svg = await satori(node, { width: 1200, height: 630, fonts });

return new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng();
}

export { recipeCard, defaultCard, renderPng };
Binary file added src/og/fonts/IBMPlexMono-Regular.ttf
Binary file not shown.
Binary file added src/og/fonts/IBMPlexMono-SemiBold.ttf
Binary file not shown.
Binary file added src/og/fonts/SourceSerif4-Regular.ttf
Binary file not shown.
Binary file added src/og/fonts/SourceSerif4-SemiBold.ttf
Binary file not shown.
2 changes: 1 addition & 1 deletion src/pages/[slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const introHtml = await renderMarkdown(splitAt === -1 ? body : body.slice(0, spl
const sectionsHtml = splitAt === -1 ? '' : await renderMarkdown(body.slice(splitAt));
---

<Base title={post.data.title} description={post.data.description} images={images} isPost>
<Base title={post.data.title} description={post.data.description} isPost slug={post.id}>
<div class="o-container">
<header class="o-spec">
<p class="o-spec__no">Entry #{String(entryNo).padStart(2, '0')} · {type}</p>
Expand Down
35 changes: 35 additions & 0 deletions src/pages/og/[slug].png.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { APIRoute } from 'astro';
import type { CollectionEntry } from 'astro:content';
import { getPostsChronological } from '../../lib/posts';
import { recipeCard, defaultCard, renderPng } from '../../lib/og';

interface Props {
post: CollectionEntry<'posts'> | null;
entryNo: number;
entryCount: number;
firstYear: number;
}

export async function getStaticPaths() {
const posts = await getPostsChronological();
const entryCount = posts.length;
const firstYear = posts[0].data.date.getUTCFullYear();

return [
{ params: { slug: 'default' }, props: { post: null, entryNo: 0, entryCount, firstYear } },
...posts.map((post, index) => ({
params: { slug: post.id },
props: { post, entryNo: index + 1, entryCount, firstYear },
})),
];
}

export const GET: APIRoute<Props> = async ({ props }) => {
const card = props.post
? recipeCard(props.post, props.entryNo)
: defaultCard(props.entryCount, props.firstYear);

return new Response(await renderPng(card), {
headers: { 'Content-Type': 'image/png' },
});
};
Loading