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
5 changes: 5 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export default defineConfig({
}),
],
prefetch: true,
// Astro 7 defaults to 'jsx' whitespace rules; keep the previous output.
compressHTML: true,
// No Astro sessions, so the Cloudflare adapter doesn't add or provision a
// SESSION KV namespace.
session: false,
trailingSlash: 'never',
// Every route renders in the Cloudflare Worker. The resvg wasm and the fonts
// are bundled into it (see src/lib/og-engine.ts and src/lib/font-loader.ts).
Expand Down
13,224 changes: 4,853 additions & 8,371 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,26 @@
"deploy": "astro build && wrangler deploy"
},
"dependencies": {
"@astrojs/cloudflare": "^12.6.13",
"@astrojs/react": "^4.2.0",
"@astrojs/sitemap": "^3.2.1",
"@astrojs/cloudflare": "^14.3.3",
"@astrojs/react": "^7.0.0",
"@astrojs/sitemap": "^3.7.4",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/playfair-display": "^5.2.8",
"@resvg/resvg-wasm": "^2.6.2",
"astro": "^5.1.0",
"astro": "^7.3.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"satori": "^0.12.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/check": "^0.9.10",
"@resvg/resvg-js": "^2.6.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.9.3",
"vitest": "^2.1.0",
"vitest": "^4.1.11",
"wrangler": "^4.141.0"
}
}
9 changes: 0 additions & 9 deletions src/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,5 @@
/// <reference types="astro/client" />

// Cloudflare runtime on Astro.locals (env bindings, ctx.waitUntil). Absent in
// `astro dev`, so read it with optional chaining.
type Runtime = import('@astrojs/cloudflare').Runtime<Record<string, never>>;
declare namespace App {
interface Locals {
runtime?: Runtime['runtime'];
}
}

// `.wasm?module` imports arrive precompiled, which is the only way Workers
// will run WebAssembly (see src/lib/og-engine.ts).
declare module '*.wasm?module' {
Expand Down
2 changes: 1 addition & 1 deletion src/pages/api/og.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
'Content-Length': String(png.byteLength),
},
});
if (cache) locals.runtime?.ctx.waitUntil(cache.put(cacheKey, response.clone()));
if (cache) locals.cfContext?.waitUntil(cache.put(cacheKey, response.clone()));
return response;
} catch (err) {
console.error('OG generation error:', err);
Expand Down
2 changes: 1 addition & 1 deletion src/pages/api/templates/[id]/thumbnail.png.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
'Access-Control-Allow-Origin': '*',
},
});
if (cache) locals.runtime?.ctx.waitUntil(cache.put(cacheKey, response.clone()));
if (cache) locals.cfContext?.waitUntil(cache.put(cacheKey, response.clone()));
return response;
} catch (err) {
console.error(`Thumbnail generation error for ${id}:`, err);
Expand Down
29 changes: 29 additions & 0 deletions tests/lib/github-stars.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { formatStarCount } from '@/lib/github-stars';

describe('formatStarCount', () => {
it('returns small counts as-is', () => {
expect(formatStarCount(0)).toBe('0');
expect(formatStarCount(999)).toBe('999');
});

it('formats 1000 as 1k', () => {
expect(formatStarCount(1000)).toBe('1k');
});

it('formats 1500 as 1.5k', () => {
expect(formatStarCount(1500)).toBe('1.5k');
});

it('formats 12000 as 12k', () => {
expect(formatStarCount(12000)).toBe('12k');
});

it('drops the trailing .0 on whole thousands', () => {
expect(formatStarCount(2000)).toBe('2k');
});

it('keeps one decimal for non-whole thousands', () => {
expect(formatStarCount(1234)).toBe('1.2k');
});
});
80 changes: 80 additions & 0 deletions tests/lib/platform-specs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { PLATFORMS, resolvePlatformPreviews } from '@/lib/platform-specs';
import type { MetaTags } from '@/lib/meta-fetcher';

describe('resolvePlatformPreviews', () => {
it('returns one preview per platform, in PLATFORMS order', () => {
const previews = resolvePlatformPreviews({}, 'https://example.com');
expect(previews).toHaveLength(8);
expect(previews.map((p) => p.platform)).toEqual(PLATFORMS.map((p) => p.id));
});

it('prefers twitter:* tags for Twitter', () => {
const tags: MetaTags = {
ogTitle: 'OG Title',
ogDescription: 'OG Description',
twitterTitle: 'Twitter Title',
twitterDescription: 'Twitter Description',
twitterImage: 'https://example.com/twitter.png',
ogImage: 'https://example.com/og.png',
};

const twitter = resolvePlatformPreviews(tags, 'https://example.com').find(
(p) => p.platform === 'twitter'
)!;
expect(twitter.title).toBe('Twitter Title');
expect(twitter.description).toBe('Twitter Description');
expect(twitter.imageUrl).toBe('https://example.com/twitter.png');
});

it('prefers <title> and meta description for Google', () => {
const tags: MetaTags = {
title: 'Page Title',
description: 'Page Description',
ogTitle: 'OG Title',
ogDescription: 'OG Description',
};

const google = resolvePlatformPreviews(tags, 'https://example.com').find(
(p) => p.platform === 'google'
)!;
expect(google.title).toBe('Page Title');
expect(google.description).toBe('Page Description');
expect(google.cardType).toBe('search');
});

it('gives Reddit an empty description', () => {
const tags: MetaTags = { ogDescription: 'Something' };
const reddit = resolvePlatformPreviews(tags, 'https://example.com').find(
(p) => p.platform === 'reddit'
)!;
expect(reddit.description).toBe('');
});

it('cuts titles to each platform max length', () => {
const longTitle = 'A'.repeat(500);
const tags: MetaTags = { ogTitle: longTitle };
const previews = resolvePlatformPreviews(tags, 'https://example.com');

for (const preview of previews) {
const spec = PLATFORMS.find((p) => p.id === preview.platform)!;
expect(preview.title.length).toBeLessThanOrEqual(spec.maxTitleLength);
}
});

it('falls back to the hostname without www. when ogSiteName is absent', () => {
const previews = resolvePlatformPreviews({}, 'https://www.example.com/page');
expect(previews[0].siteName).toBe('example.com');
});

it('uses ogSiteName when present', () => {
const tags: MetaTags = { ogSiteName: 'My Site' };
const previews = resolvePlatformPreviews(tags, 'https://example.com');
expect(previews[0].siteName).toBe('My Site');
});

it('returns null imageUrl when there is no og:image', () => {
const previews = resolvePlatformPreviews({}, 'https://example.com');
expect(previews[0].imageUrl).toBeNull();
});
});
115 changes: 115 additions & 0 deletions tests/templates/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest';
import {
truncate,
autoFontSize,
parseColor,
linearGradient,
mixColors,
isLightColor,
contrastText,
} from '@/templates/utils';

describe('truncate', () => {
it('returns short text unchanged', () => {
expect(truncate('hello', 10)).toBe('hello');
});

it('returns text exactly at maxLength unchanged', () => {
expect(truncate('hello', 5)).toBe('hello');
});

it('truncates long text with an ellipsis and respects maxLength', () => {
const result = truncate('hello world', 8);
expect(result.endsWith('…')).toBe(true);
expect(result.length).toBeLessThanOrEqual(8);
});

it('trims trailing whitespace before adding the ellipsis', () => {
// 'hello ' sliced to 7 chars => 'hello ' then trimEnd => 'hello' + '…'
expect(truncate('hello world', 7)).toBe('hello…');
});
});

describe('autoFontSize', () => {
const breakpoints = [
{ maxLen: 20, size: 48 },
{ maxLen: 40, size: 32 },
{ maxLen: 80, size: 24 },
];

it('picks the first matching breakpoint', () => {
expect(autoFontSize('short', breakpoints)).toBe(48);
});

it('picks a smaller size as text grows', () => {
expect(autoFontSize('x'.repeat(30), breakpoints)).toBe(32);
});

it('falls back to the last size when nothing matches', () => {
expect(autoFontSize('x'.repeat(200), breakpoints)).toBe(24);
});
});

describe('parseColor', () => {
it('adds a leading hash when missing', () => {
expect(parseColor('ffffff')).toBe('#ffffff');
});

it('leaves an existing hash intact', () => {
expect(parseColor('#000000')).toBe('#000000');
});
});

describe('linearGradient', () => {
it('builds a gradient string from an angle and stops', () => {
expect(linearGradient(90, '#000', '#fff')).toBe(
'linear-gradient(90deg, #000, #fff)'
);
});

it('supports a single stop', () => {
expect(linearGradient(45, '#123456')).toBe('linear-gradient(45deg, #123456)');
});
});

describe('mixColors', () => {
it('mixes black and white evenly to mid grey', () => {
expect(mixColors('#000000', '#ffffff', 0.5)).toBe('#808080');
});

it('returns the first color at ratio 0', () => {
expect(mixColors('#000000', '#ffffff', 0)).toBe('#000000');
});

it('returns the second color at ratio 1', () => {
expect(mixColors('#000000', '#ffffff', 1)).toBe('#ffffff');
});

it('accepts colors without a leading hash', () => {
expect(mixColors('000000', 'ffffff', 0.5)).toBe('#808080');
});
});

describe('isLightColor', () => {
it('treats pure white as light', () => {
expect(isLightColor('#ffffff')).toBe(true);
});

it('treats pure black as not light', () => {
expect(isLightColor('#000000')).toBe(false);
});

it('returns false for an invalid color', () => {
expect(isLightColor('not-a-color')).toBe(false);
});
});

describe('contrastText', () => {
it('returns black text on a light background', () => {
expect(contrastText('#ffffff')).toBe('#000000');
});

it('returns white text on a dark background', () => {
expect(contrastText('#000000')).toBe('#FFFFFF');
});
});
2 changes: 1 addition & 1 deletion wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "ogcops",
// Built by `astro build` with @astrojs/cloudflare. Every page and the image
// API render in the Worker; dist/ holds the client assets and fonts.
"main": "./dist/_worker.js/index.js",
"main": "@astrojs/cloudflare/entrypoints/server",
"compatibility_date": "2026-09-24",
"compatibility_flags": ["nodejs_compat"],
"assets": {
Expand Down
Loading