From d692437aeafaad7ec0ce035d6aced3a33132fe2d Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Thu, 16 Jul 2026 16:01:43 +0100 Subject: [PATCH] feat(projects): show pinned GitHub repositories --- .env.example | 6 ++ README.md | 5 +- src/lib/services/github.ts | 162 +++++++++++++++++++++++++++++++ src/routes/+page.svelte | 18 ++-- src/routes/about/+page.server.ts | 13 ++- src/routes/about/+page.svelte | 8 +- src/routes/api/home/+server.ts | 13 ++- 7 files changed, 202 insertions(+), 23 deletions(-) create mode 100644 src/lib/services/github.ts diff --git a/.env.example b/.env.example index 460f92c..1d3aaee 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,12 @@ PUBLIC_ATPROTO_DID=did:plc:your-did-here PUBLIC_LEAFLET_BLOG_PUBLICATION=your-blog-rkey +# GitHub projects +GITHUB_USERNAME=your-github-username +# Optional: enables the official GraphQL pinned-items API. Without it, the +# public GitHub profile is used as a fallback. +GITHUB_TOKEN= + # Site Metadata # PUBLIC_SITE_TITLE is used in the header and as a suffix for subpage titles PUBLIC_SITE_TITLE="Ewan Croft" diff --git a/README.md b/README.md index 32f3060..fb4e7f2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A personal website and blog built with [SvelteKit](https://kit.svelte.dev/), fea - **Blog System**: Markdown-based blog posts with automatic date-based routing - **AT Protocol Integration**: Fetch and display Bluesky posts and profiles - **Content Rendering**: Custom Leaflet components for flexible content blocks (code, embeds, images, math) -- **Project Showcase**: Display and manage project listings +- **Project Showcase**: Display pinned repositories from a GitHub profile - **Social Features**: Comment sections, share buttons, and recommendation system - **API Endpoints**: REST API for blog posts, recommendations, and subscriptions - **Webhooks**: GitHub webhook support for CI/CD integration @@ -90,6 +90,9 @@ ATPROTO_PASSWORD=your_password # Other configuration PUBLIC_SITE_URL=https://your-domain.com +GITHUB_USERNAME=your_github_username +# Optional; public profile parsing is used when this is unset +GITHUB_TOKEN=github_token_with_public_repository_read_access ``` ## Configuration diff --git a/src/lib/services/github.ts b/src/lib/services/github.ts new file mode 100644 index 0000000..b1b1b39 --- /dev/null +++ b/src/lib/services/github.ts @@ -0,0 +1,162 @@ +export interface GitHubProject { + name: string; + description: string; + url: string; + language?: string; + languageColor?: string; +} + +type Fetch = typeof globalThis.fetch; + +const PINNED_REPOSITORIES_QUERY = ` + query PinnedRepositories($login: String!) { + user(login: $login) { + pinnedItems(first: 6, types: [REPOSITORY]) { + nodes { + ... on Repository { + name + description + url + primaryLanguage { + name + color + } + } + } + } + } + } +`; + +function decodeHtml(value: string): string { + const namedEntities: Record = { + amp: "&", + apos: "'", + gt: ">", + lt: "<", + nbsp: " ", + quot: '"', + }; + + return value + .replace(/<[^>]+>/g, "") + .replace(/&#x([\da-f]+);/gi, (_, hex: string) => + String.fromCodePoint(Number.parseInt(hex, 16)), + ) + .replace(/&#(\d+);/g, (_, decimal: string) => + String.fromCodePoint(Number.parseInt(decimal, 10)), + ) + .replace( + /&([a-z]+);/gi, + (entity, name: string) => namedEntities[name.toLowerCase()] ?? entity, + ) + .replace(/\s+/g, " ") + .trim(); +} + +async function fetchPinnedWithGraphQL( + username: string, + token: string, + fetchFn: Fetch, +): Promise { + const response = await fetchFn("https://api.github.com/graphql", { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "ewancroft.uk", + }, + body: JSON.stringify({ + query: PINNED_REPOSITORIES_QUERY, + variables: { login: username }, + }), + }); + + if (!response.ok) + throw new Error(`GitHub GraphQL returned ${response.status}`); + + const payload = await response.json(); + if (payload.errors?.length) throw new Error(payload.errors[0].message); + + return (payload.data?.user?.pinnedItems?.nodes ?? []).map( + (repository: any) => ({ + name: repository.name, + description: repository.description ?? "", + url: repository.url, + language: repository.primaryLanguage?.name, + languageColor: repository.primaryLanguage?.color, + }), + ); +} + +async function fetchPinnedFromProfile( + username: string, + fetchFn: Fetch, +): Promise { + const response = await fetchFn( + `https://github.com/${encodeURIComponent(username)}`, + { + headers: { + Accept: "text/html", + "User-Agent": "ewancroft.uk", + }, + }, + ); + + if (!response.ok) + throw new Error(`GitHub profile returned ${response.status}`); + + const html = await response.text(); + const items = + html.match( + /]*class="[^"]*\bpinned-item-list-item\b[^"]*"[^>]*>[\s\S]*?<\/li>/gi, + ) ?? []; + + return items.flatMap((item): GitHubProject[] => { + const repository = item.match( + /href="\/([^"?#]+)"[\s\S]*?]*class="repo"[^>]*>([\s\S]*?)<\/span>/i, + ); + if (!repository) return []; + + const path = repository[1]; + const [owner, name] = path.split("/"); + if (!owner || !name || owner.toLowerCase() !== username.toLowerCase()) + return []; + + const description = item.match( + /]*class="[^"]*\bpinned-item-desc\b[^"]*"[^>]*>([\s\S]*?)<\/p>/i, + ); + const language = item.match( + /]*itemprop="programmingLanguage"[^>]*>([\s\S]*?)<\/span>/i, + ); + + return [ + { + name: decodeHtml(name), + description: description ? decodeHtml(description[1]) : "", + url: `https://github.com/${path}`, + language: language ? decodeHtml(language[1]) : undefined, + }, + ]; + }); +} + +export async function fetchPinnedGitHubProjects( + username: string, + fetchFn: Fetch, + token?: string, +): Promise { + if (token) { + try { + return await fetchPinnedWithGraphQL(username, token, fetchFn); + } catch (error) { + console.warn( + "GitHub GraphQL pins unavailable; using public profile", + error, + ); + } + } + + return fetchPinnedFromProfile(username, fetchFn); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b0b314a..77f317a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -16,13 +16,12 @@ let kibunStatus = $state(null); let musicStatus = $state(null); let posts = $state(null); - let sifaProjects = $state(null); + let githubProjects = $state(null); + let githubUsername = $state('ewanc26'); let publications = $state(null); let links = $state(null); - let shuffledProjects = $derived( - sifaProjects ? [...sifaProjects].sort(() => Math.random() - 0.5).slice(0, 6) : [] - ); + let pinnedProjects = $derived(githubProjects ? githubProjects.slice(0, 6) : []); onMount(async () => { // Fetch remaining data in parallel @@ -30,7 +29,8 @@ kibunStatus = d.kibunStatus; musicStatus = d.musicStatus; posts = d.posts; - sifaProjects = d.sifaProjects; + githubProjects = d.githubProjects; + githubUsername = d.githubUsername; publications = d.publications; links = d.links; }).catch(e => console.error("Failed to load home data", e)); @@ -128,11 +128,11 @@

Projects

Tools and experiments

- {#if sifaProjects === null} + {#if githubProjects === null} - {:else if sifaProjects && sifaProjects.length > 0} + {:else if githubProjects && githubProjects.length > 0}
- {#each shuffledProjects as project} + {#each pinnedProjects as project} {#if project.url} {project.name} @@ -153,7 +153,7 @@ {/if} {/each}
- All projects + All repositories {:else} { + const githubUsername = env.GITHUB_USERNAME || "ewanc26"; setHeaders({ "Cache-Control": "public, s-maxage=300, stale-while-revalidate=3600", }); @@ -58,9 +60,10 @@ export const load: PageServerLoad = async ({ fetch, setHeaders }) => { PUBLIC_ATPROTO_DID, fetch, ).catch(() => []); - const sifaProjectsPromise = fetchSifaProjects( - PUBLIC_ATPROTO_DID, + const githubProjectsPromise = fetchPinnedGitHubProjects( + githubUsername, fetch, + env.GITHUB_TOKEN, ).catch(() => []); const profile = await profilePromise; @@ -74,7 +77,7 @@ export const load: PageServerLoad = async ({ fetch, setHeaders }) => { sifaEducation: sifaEducationPromise, sifaLanguages: sifaLanguagesPromise, sifaExternalAccounts: sifaExternalAccountsPromise, - sifaProjects: sifaProjectsPromise, + githubProjects: githubProjectsPromise, }, }; }; diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index 834bc9e..27c9bd1 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -147,12 +147,12 @@

Projects

- {#await data.lazy.sifaProjects} + {#await data.lazy.githubProjects} - {:then sifaProjects} - {#if sifaProjects && sifaProjects.length > 0} + {:then githubProjects} + {#if githubProjects && githubProjects.length > 0}