Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e4f2afa
feat(alumni): source-lock kind zero alumni data
jodobear Jun 7, 2026
42aad1e
feat(alumni): add source-safe profile helpers
jodobear Jun 7, 2026
ed2c3df
feat(alumni): add local alumni route
jodobear Jun 7, 2026
d75168b
feat(alumni): render profile grid and QR dialog
jodobear Jun 7, 2026
f5f7f1d
fix(alumni): avoid nested main landmark
jodobear Jun 7, 2026
83dede3
fix(alumni): harden validation and QR dialog
jodobear Jun 7, 2026
3de58c8
fix(alumni): simplify hero and directory copy
jodobear Jun 12, 2026
cae68ec
fix(alumni): tighten profile card layout
jodobear Jun 12, 2026
26b4e3f
Revert "fix(alumni): tighten profile card layout"
jodobear Jun 12, 2026
2e19ec5
fix(alumni): simplify profile card actions
jodobear Jun 12, 2026
f36d39f
fix(alumni): refresh source list and polish grid
jodobear Jun 19, 2026
e7f2dbb
style(alumni): add grid breathing room
jodobear Jun 19, 2026
56556c6
style(alumni): increase grid spacing
jodobear Jun 19, 2026
61f13b7
Revert "style(alumni): increase grid spacing"
jodobear Jun 19, 2026
b9c25ef
style(alumni): add grid top spacing
jodobear Jun 19, 2026
265d061
perf(alumni): serve cached avatar assets
jodobear Jun 19, 2026
549fc31
fix(nav): update community footer links
jodobear Jun 20, 2026
1394baf
fix(alumni): satisfy curly lint rule
jodobear Jun 20, 2026
ec03ac7
feat(alumni): order opening sample by familiar faces
jodobear Jun 20, 2026
c91218d
Revert "feat(alumni): order opening sample by familiar faces"
jodobear Jun 20, 2026
3c8656b
feat(alumni): mix opening directory sample
jodobear Jun 20, 2026
b23adeb
chore(alumni): reshuffle opening sample
jodobear Jun 20, 2026
2347730
fix(alumni): use logo social preview
jodobear Jun 27, 2026
2f14577
fix(alumni): use endurance crew social preview
jodobear Jun 27, 2026
bac8f45
fix(alumni): optimize social preview as webp
jodobear Jun 27, 2026
005e5d5
fix(alumni): use jpg social preview
jodobear Jun 28, 2026
4984565
fix(meta): use preview origin for social cards
jodobear Jun 28, 2026
92f448a
fix(alumni): simplify directory presentation
dergigi Jun 29, 2026
751f4d2
fix(alumni): simplify hero copy and actions
dergigi Jun 29, 2026
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"build": "astro build",
"preview": "astro preview",
"check": "astro check",
"cache-alumni-avatars": "bun scripts/cache-alumni-avatars.mjs",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.astro",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx,.astro --fix",
"format": "prettier --write .",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
175 changes: 175 additions & 0 deletions scripts/cache-alumni-avatars.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/usr/bin/env bun
import { createHash } from 'node:crypto';
import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import sharp from 'sharp';

const ROOT = new URL('..', import.meta.url);
const DATA_PATH = new URL('src/data/sovengAlumni.json', `${ROOT}/`);
const MANIFEST_PATH = new URL('src/data/sovengAlumniAvatarCache.json', `${ROOT}/`);
const OUTPUT_DIR = new URL('public/images/alumni/avatars/', `${ROOT}/`);
const PUBLIC_PREFIX = '/images/alumni/avatars/';
const AVATAR_SIZE = 128;
const CONCURRENCY = 6;
const MAX_IMAGE_BYTES = 6 * 1024 * 1024;
const REQUEST_TIMEOUT_MS = 20_000;
const USER_AGENT = 'SovereignEngineering.io alumni avatar cache (+https://sovereignengineering.io/alumni)';
const STRICT = process.argv.includes('--strict');

function cleanText(value) {
if (typeof value !== 'string') return undefined;
const cleaned = value.replace(/\s+/g, ' ').trim();
return cleaned.length > 0 ? cleaned : undefined;
}

function safeImageUrl(value) {
const imageHref = cleanText(value);
if (!imageHref) return undefined;

try {
const url = new URL(imageHref);
if (url.protocol !== 'https:') return undefined;
if (url.username || url.password) return undefined;
if (!url.hostname) return undefined;
return url.href;
} catch {
return undefined;
}
}

function sha256(buffer) {
return createHash('sha256').update(buffer).digest('hex');
}

function avatarFileName(profile) {
return `${profile.npub}.webp`;
}

async function fetchAvatar(source) {
const response = await fetch(source, {
headers: { accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', 'user-agent': USER_AGENT },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);

const contentLength = Number(response.headers.get('content-length') || 0);
if (contentLength > MAX_IMAGE_BYTES) throw new Error(`too large: ${contentLength} bytes`);

const input = Buffer.from(await response.arrayBuffer());
if (input.length > MAX_IMAGE_BYTES) throw new Error(`too large: ${input.length} bytes`);
return input;
}

async function renderAvatar(input) {
return await sharp(input, { animated: false, failOn: 'none', limitInputPixels: 16_000_000 })
.rotate()
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover' })
.webp({ effort: 4, quality: 78 })
.toBuffer();
}

async function processProfile(profile) {
const source = safeImageUrl(profile.picture);
if (!source) return { status: 'skipped', profile };

const input = await fetchAvatar(source);
const output = await renderAvatar(input);
const filename = avatarFileName(profile);
await writeFile(new URL(filename, OUTPUT_DIR), output);

return {
status: 'cached',
profile,
entry: {
src: `${PUBLIC_PREFIX}${filename}`,
source,
width: AVATAR_SIZE,
height: AVATAR_SIZE,
bytes: output.length,
sha256: sha256(output),
},
};
}

async function mapConcurrent(items, mapper, concurrency) {
const results = new Array(items.length);
let nextIndex = 0;

async function worker() {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
try {
results[index] = await mapper(items[index], index);
} catch (error) {
results[index] = { status: 'failed', profile: items[index], error };
}
}
}

await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
return results;
}

async function removeStaleFiles(keep) {
const files = await readdir(OUTPUT_DIR).catch(() => []);
await Promise.all(
files
.filter((file) => file.endsWith('.webp') && !keep.has(file))
.map((file) => unlink(join(OUTPUT_DIR.pathname, file))),
);
}

const profiles = JSON.parse(await readFile(DATA_PATH, 'utf8'));
const profilesWithPictures = profiles.filter((profile) => safeImageUrl(profile.picture));

await mkdir(OUTPUT_DIR, { recursive: true });
const results = await mapConcurrent(profilesWithPictures, processProfile, CONCURRENCY);
const failures = results.filter((result) => result.status === 'failed');

for (const failure of failures) {
console.warn(`${failure.profile.npub}: ${failure.error?.message || failure.error}`);
}

if (STRICT && failures.length > 0) {
throw new Error(`Failed to cache ${failures.length} alumni avatar(s)`);
}

const avatars = Object.fromEntries(
results
.filter((result) => result.status === 'cached')
.map((result) => [result.profile.npub, result.entry])
.sort(([left], [right]) => left.localeCompare(right)),
);
const failed = Object.fromEntries(
failures
.map((failure) => [
failure.profile.npub,
{
source: safeImageUrl(failure.profile.picture),
error: failure.error?.message || String(failure.error),
},
])
.sort(([left], [right]) => left.localeCompare(right)),
);

await removeStaleFiles(new Set(Object.values(avatars).map((entry) => entry.src.replace(PUBLIC_PREFIX, ''))));
await writeFile(
MANIFEST_PATH,
`${JSON.stringify(
{
generatedAt: new Date().toISOString(),
format: 'webp',
width: AVATAR_SIZE,
height: AVATAR_SIZE,
avatars,
failed,
},
null,
2,
)}\n`,
);

console.log(`Cached ${Object.keys(avatars).length}/${profilesWithPictures.length} alumni avatars to ${OUTPUT_DIR.pathname}`);
if (failures.length > 0) console.log(`Skipped ${failures.length} broken upstream avatar(s); page will show initials for them.`);
Loading
Loading