From 5229f4986544c05a2021a78399fcc632b4980e88 Mon Sep 17 00:00:00 2001 From: Hyde <108911977+Hyaxon@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:49:19 -0500 Subject: [PATCH] feat: enhanced project page --- README.md | 17 +- astro.config.mjs | 6 +- src/components/ui/ProjectSidebar.astro | 251 ++++++++++++++++++++++++ src/content.config.ts | 18 ++ src/data/projects.ts | 4 +- src/lib/github/client.ts | 9 +- src/lib/github/repositories.ts | 43 ++++- src/lib/markdown-theme.ts | 53 ++++++ src/lib/projects/github-loader.ts | 40 +++- src/lib/projects/types.ts | 3 + src/pages/projects/[...id].astro | 252 ++++++++++++++++++++++--- src/styles/tokens.css | 10 + tests/github-repositories.test.ts | 77 ++++++++ 13 files changed, 749 insertions(+), 34 deletions(-) create mode 100644 src/components/ui/ProjectSidebar.astro create mode 100644 src/lib/markdown-theme.ts create mode 100644 tests/github-repositories.test.ts diff --git a/README.md b/README.md index 39c8bf3..cea8e10 100644 --- a/README.md +++ b/README.md @@ -124,8 +124,21 @@ non-draft projects. Local Markdown/MDX projects remain in `src/content/projects/ Set `GITHUB_TOKEN` in the build environment (or a local `.env` file) to use authenticated GitHub API limits. It is only used during content loading. Missing -READMEs produce an empty body; other GitHub errors fail the build rather than -publishing incomplete content. +READMEs produce an empty body; other errors fetching the repository or README +fail the build rather than publishing incomplete content. + +Project breakdowns include a metadata sidebar. Stars, forks, watchers, creation +and update dates come from the repository response; language percentages and +contributor counts use GitHub's language and paginated contributor endpoints. +Those two optional endpoints log a warning and omit their data on failure. +These values refresh at build time, with no browser API requests or exposed token. + +The repository homepage supplies the live deployment link. Set `demo` to override +it and `docs` to add a documentation link in `src/data/projects.ts`. Use +`linkedPosts: ["post-id"]` to show related published posts (drafts are excluded). +Local projects support the same links and references in frontmatter; GitHub-only +statistics stay hidden unless supplied. No placeholder links or statistics are +generated. Run the README rendering regression checks with: diff --git a/astro.config.mjs b/astro.config.mjs index f184555..6b4a59e 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -4,6 +4,7 @@ import cloudflare from "@astrojs/cloudflare"; import mdx from "@astrojs/mdx"; import { satteri } from "@astrojs/markdown-satteri"; import { githubMarkdownPlugins } from "./src/lib/github/markdown.ts"; +import { markdownShikiConfig } from "./src/lib/markdown-theme.ts"; import sitemap from "@astrojs/sitemap"; // https://astro.build/config @@ -16,7 +17,10 @@ export default defineConfig({ session: false, - markdown: { processor: satteri(githubMarkdownPlugins) }, + markdown: { + processor: satteri(githubMarkdownPlugins), + shikiConfig: markdownShikiConfig, + }, integrations: [ mdx(), diff --git a/src/components/ui/ProjectSidebar.astro b/src/components/ui/ProjectSidebar.astro new file mode 100644 index 0000000..39a8128 --- /dev/null +++ b/src/components/ui/ProjectSidebar.astro @@ -0,0 +1,251 @@ +--- +import type { ProjectEntry } from "../../lib/projects/selectors"; +import { getPosts } from "../../lib/posts/selectors"; +import Card from "./Card.astro"; + +interface Props { + project: ProjectEntry; +} + +const { project } = Astro.props; +const { data } = project; +const repo = "repo" in data ? data.repo : data.github; +const stats = data.repositoryStats; +const tags = [...new Set([data.category, ...data.tags, ...data.technologies])]; +const linkedIds = new Set(data.linkedPosts.map((post) => post.id)); +const posts = (await getPosts()).filter((post) => linkedIds.has(post.id)); +const createdAt = data.createdAt ?? data.publishedAt; +const updatedAt = data.updatedAt ?? data.pushedAt; +const links = [ + { label: "github source", href: repo }, + { label: "live deployment", href: data.demo }, + { label: "docs", href: data.docs }, +].filter((link) => link.href); +const formatDate = (date: Date) => + date.toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + timeZone: "UTC", + }); +const formatNumber = (value: number) => value.toLocaleString("en-US"); +const formatPercentage = (value: number) => + value < 0.1 ? "<0.1" : String(Number(value.toFixed(1))); +--- + + + + diff --git a/src/content.config.ts b/src/content.config.ts index a4293ac..1eaa099 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -30,6 +30,7 @@ const projectSchema = z.object({ description: z.string(), category: z.enum(projectCategories).default("software"), publishedAt: z.coerce.date().optional(), + createdAt: z.coerce.date().optional(), updatedAt: z.coerce.date().optional(), pushedAt: z.coerce.date().optional(), draft: z.boolean().default(false), @@ -40,6 +41,23 @@ const projectSchema = z.object({ linkedPosts: z.array(reference("posts")).default([]), github: z.url().optional(), demo: z.url().optional(), + docs: z.url().optional(), + repositoryStats: z + .object({ + stars: z.number().int().nonnegative(), + forks: z.number().int().nonnegative(), + watchers: z.number().int().nonnegative(), + contributors: z.number().int().nonnegative().optional(), + }) + .optional(), + languages: z + .array( + z.object({ + name: z.string(), + percentage: z.number().min(0).max(100), + }), + ) + .default([]), status: z.enum(projectStatuses).default("active"), }); diff --git a/src/data/projects.ts b/src/data/projects.ts index fa18735..f807bd7 100644 --- a/src/data/projects.ts +++ b/src/data/projects.ts @@ -1,5 +1,7 @@ import type { ProjectDefinition } from "../lib/projects/types"; +// GitHub supplies README content, topics, stats, languages, dates, and the +// homepage link. Add demo/docs URLs or linkedPosts IDs here when needed. export const projects: ProjectDefinition[] = [ { slug: "mr-helpsworth", @@ -103,7 +105,7 @@ export const projects: ProjectDefinition[] = [ { slug: "giad", title: "GIAD", - repo: "https://github.com/Hyaxon/giad-reviwer", + repo: "https://github.com/Hyaxon/giad-reviewer", category: "tools", visible: true, status: "active", diff --git a/src/lib/github/client.ts b/src/lib/github/client.ts index 23856fd..ac43bc6 100644 --- a/src/lib/github/client.ts +++ b/src/lib/github/client.ts @@ -35,15 +35,16 @@ export async function githubRequest( ); } + if (response.status === 204) return undefined as T; return response.json() as Promise; } export class GitHubRequestError extends Error { - constructor( - public readonly status: number, - message: string, - ) { + readonly status: number; + + constructor(status: number, message: string) { super(message); + this.status = status; this.name = "GitHubRequestError"; } } diff --git a/src/lib/github/repositories.ts b/src/lib/github/repositories.ts index 318907f..2f37c4e 100644 --- a/src/lib/github/repositories.ts +++ b/src/lib/github/repositories.ts @@ -1,4 +1,4 @@ -import { githubRequest, GitHubRequestError } from "./client"; +import { githubRequest, GitHubRequestError } from "./client.ts"; export interface GitHubRepository { name: string; @@ -8,6 +8,11 @@ export interface GitHubRepository { description: string | null; html_url: string; + homepage: string | null; + created_at: string; + stargazers_count: number; + forks_count: number; + subscribers_count: number; updated_at: string; pushed_at: string; @@ -48,6 +53,42 @@ export function getRepository(owner: string, repo: string) { return githubRequest(`/repos/${owner}/${repo}`); } +export async function getRepositoryLanguages(owner: string, repo: string) { + const languages = await githubRequest>( + `/repos/${owner}/${repo}/languages`, + ); + const entries = Object.entries(languages).filter(([, bytes]) => bytes > 0); + const total = entries.reduce((sum, [, bytes]) => sum + bytes, 0); + return entries + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([name, bytes]) => ({ name, percentage: (bytes / total) * 100 })); +} + +export async function getRepositoryContributorCount( + owner: string, + repo: string, +) { + let count = 0; + for (let page = 1; ; page++) { + const contributors = await githubRequest( + `/repos/${owner}/${repo}/contributors?per_page=100&page=${page}`, + ); + // GitHub returns 204 for an empty repository. + count += contributors?.length ?? 0; + if (!contributors || contributors.length < 100) return count; + } +} + +export function repositoryHomepage(homepage: string | null) { + if (!homepage) return undefined; + try { + const url = new URL(homepage); + return ["https:", "http:"].includes(url.protocol) ? url.href : undefined; + } catch { + return undefined; + } +} + interface GitHubReadme { content: string; encoding: string; diff --git a/src/lib/markdown-theme.ts b/src/lib/markdown-theme.ts new file mode 100644 index 0000000..9cf2ece --- /dev/null +++ b/src/lib/markdown-theme.ts @@ -0,0 +1,53 @@ +import type { SatteriMarkdownProcessorOptions } from "@astrojs/markdown-satteri"; + +// CSS variables keep generated Markdown and MDX in sync with the site palette. +export const markdownShikiConfig: SatteriMarkdownProcessorOptions["shikiConfig"] = + { + theme: { + name: "hyde-terminal", + type: "dark", + colors: { + "editor.background": "var(--color-panel)", + "editor.foreground": "var(--color-text)", + }, + tokenColors: [ + { + scope: ["comment", "punctuation.definition.comment"], + settings: { foreground: "var(--color-code-comment)" }, + }, + { + scope: ["keyword", "storage"], + settings: { foreground: "var(--color-code-keyword)" }, + }, + { + scope: ["string", "markup.inserted"], + settings: { foreground: "var(--color-code-string)" }, + }, + { + scope: ["constant", "variable.language"], + settings: { foreground: "var(--color-code-constant)" }, + }, + { + scope: ["entity.name.function", "support.function"], + settings: { foreground: "var(--color-code-function)" }, + }, + { + scope: [ + "entity.name.type", + "support.type", + "entity.name.tag", + "entity.other.attribute-name", + ], + settings: { foreground: "var(--color-code-type)" }, + }, + { + scope: "punctuation", + settings: { foreground: "var(--color-code-punctuation)" }, + }, + { + scope: ["invalid", "markup.deleted"], + settings: { foreground: "var(--color-code-deleted)" }, + }, + ], + }, + }; diff --git a/src/lib/projects/github-loader.ts b/src/lib/projects/github-loader.ts index 6aaa922..2ee9460 100644 --- a/src/lib/projects/github-loader.ts +++ b/src/lib/projects/github-loader.ts @@ -5,6 +5,9 @@ import { projects } from "../../data/projects"; import { getRepository, getRepositoryReadme, + getRepositoryLanguages, + getRepositoryContributorCount, + repositoryHomepage, parseGitHubRepoUrl, } from "../github/repositories"; @@ -12,7 +15,7 @@ export function githubProjectsLoader(): Loader { return { name: "github-projects", - async load({ store, parseData, renderMarkdown }) { + async load({ store, parseData, renderMarkdown, logger }) { const entries = []; const githubProjects = projects.filter( @@ -41,7 +44,29 @@ export function githubProjectsLoader(): Loader { ); } - const readme = await getRepositoryReadme(owner, repo); + // Auxiliary metadata should not prevent an otherwise valid README + // from publishing if GitHub temporarily cannot serve these endpoints. + const optionalMetadata = async ( + label: string, + request: Promise, + ) => { + try { + return await request; + } catch { + logger.warn( + `Could not load ${label} for ${owner}/${repo}; omitting it.`, + ); + return undefined; + } + }; + const [readme, languages, contributors] = await Promise.all([ + getRepositoryReadme(owner, repo), + optionalMetadata("languages", getRepositoryLanguages(owner, repo)), + optionalMetadata( + "contributors", + getRepositoryContributorCount(owner, repo), + ), + ]); const data = await parseData({ id: definition.slug, @@ -70,6 +95,17 @@ export function githubProjectsLoader(): Loader { repoOwner: owner, repoName: repo, + demo: definition.demo ?? repositoryHomepage(repository.homepage), + docs: definition.docs, + createdAt: repository.created_at, + repositoryStats: { + stars: repository.stargazers_count, + forks: repository.forks_count, + watchers: repository.subscribers_count, + contributors, + }, + languages: languages ?? [], + updatedAt: repository.updated_at, pushedAt: repository.pushed_at, diff --git a/src/lib/projects/types.ts b/src/lib/projects/types.ts index 54fb96b..4f512dc 100644 --- a/src/lib/projects/types.ts +++ b/src/lib/projects/types.ts @@ -28,6 +28,9 @@ export interface ProjectDefinition { // Optional metadata overrides. title?: string; description?: string; + // Defaults to the repository homepage when available. + demo?: string; + docs?: string; category: ProjectCategory; tags?: string[]; diff --git a/src/pages/projects/[...id].astro b/src/pages/projects/[...id].astro index 5cae5d6..c8ec807 100644 --- a/src/pages/projects/[...id].astro +++ b/src/pages/projects/[...id].astro @@ -1,9 +1,8 @@ --- import { render } from "astro:content"; import { getProjects } from "../../lib/projects/selectors"; -import ContentLayout from "../../layouts/ContentLayout.astro"; -import ItemBox from "../../components/ui/ItemBox.astro"; -import Card from "../../components/ui/Card.astro"; +import BaseLayout from "../../layouts/BaseLayout.astro"; +import ProjectSidebar from "../../components/ui/ProjectSidebar.astro"; export async function getStaticPaths() { return (await getProjects()).map((project) => ({ @@ -14,29 +13,236 @@ export async function getStaticPaths() { const { project } = Astro.props; const { Content } = await render(project); +const isGitHubProject = project.collection === "githubProjects"; --- - - -

< Projects

-
+
+
+
+

+ {isGitHubProject ? "README.md" : "Project breakdown"} +

+
+ {project.body?.trim() ? ( + + ) : ( +

+ No README has been added yet. Visit the source repository for more + about this project. +

+ )} +
+
+ +
+ +
+ - -

< Home

-
- - - - - - -

< Projects

-
- - -

< Home

-
-
+ diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 271889a..c1893de 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -18,6 +18,16 @@ --color-text-muted: #778078; --color-border: #89938b; + /* Terminal syntax highlighting */ + --color-code-comment: #8b978e; + --color-code-keyword: #d1d9ca; + --color-code-string: #a4b79b; + --color-code-constant: #c6bea5; + --color-code-function: #a7c0b6; + --color-code-type: #b9c6b1; + --color-code-punctuation: #89938b; + --color-code-deleted: #c4a49a; + /* Typography */ --font-mono: "Departure Mono", "SFMono-Regular", Consolas, monospace; --font-readable: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; diff --git a/tests/github-repositories.test.ts b/tests/github-repositories.test.ts new file mode 100644 index 0000000..9d17bae --- /dev/null +++ b/tests/github-repositories.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + getRepositoryContributorCount, + getRepositoryLanguages, + repositoryHomepage, +} from "../src/lib/github/repositories.ts"; + +test("language percentages use byte totals and sort largest first", async (t) => { + t.mock.method(globalThis, "fetch", async () => + Response.json({ CSS: 150, TypeScript: 800, HTML: 50, Empty: 0 }), + ); + assert.deepEqual(await getRepositoryLanguages("example", "repo"), [ + { name: "TypeScript", percentage: 80 }, + { name: "CSS", percentage: 15 }, + { name: "HTML", percentage: 5 }, + ]); +}); + +test("empty language data has no invented percentages", async (t) => { + t.mock.method(globalThis, "fetch", async () => Response.json({})); + assert.deepEqual(await getRepositoryLanguages("example", "repo"), []); +}); + +test("contributor counts include subsequent pages", async (t) => { + const paths: string[] = []; + t.mock.method(globalThis, "fetch", async (url: string) => { + paths.push(url); + return Response.json( + Array.from({ length: paths.length === 1 ? 100 : 3 }, () => ({ + login: "contributor", + })), + ); + }); + assert.equal(await getRepositoryContributorCount("example", "repo"), 103); + assert.deepEqual(paths, [ + "https://api.github.com/repos/example/repo/contributors?per_page=100&page=1", + "https://api.github.com/repos/example/repo/contributors?per_page=100&page=2", + ]); +}); + +test("empty repositories return zero contributors on HTTP 204", async (t) => { + t.mock.method( + globalThis, + "fetch", + async () => new Response(null, { status: 204 }), + ); + assert.equal(await getRepositoryContributorCount("example", "repo"), 0); +}); + +test("failed metadata requests do not masquerade as zero counts", async (t) => { + t.mock.method( + globalThis, + "fetch", + async () => new Response("Unavailable", { status: 503 }), + ); + await assert.rejects( + getRepositoryContributorCount("example", "repo"), + /Status: 503/, + ); +}); + +test("repository homepage accepts only absolute web links", () => { + assert.equal( + repositoryHomepage("https://example.com"), + "https://example.com/", + ); + for (const homepage of [ + null, + "", + "example.com", + "javascript:alert(1)", + "ftp://example.com", + ]) { + assert.equal(repositoryHomepage(homepage), undefined); + } +});