chore: switch GitHub widget and changelog to Gitea

- Sidebar "GitHub" widget renamed to "Gitea", links point to gitea.giersig.eu/giersig.eu
- Runtime widget JS fetches commits/repos/PRs directly from Gitea API
- Build-time data files (githubActivity, githubRepos) switched from GitHub API to Gitea API
- changelog.njk fetches from Gitea API directly with client-side commit categorisation
- GITEA_URL / GITEA_ORG added to deploy.yml build env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-31 12:45:36 +02:00
parent 0f250c4b8d
commit a6f87de59d
8 changed files with 222 additions and 396 deletions

View File

@@ -1,298 +1,91 @@
/**
* GitHub Activity Data
* Fetches from Indiekit's endpoint-github public API
* Falls back to direct GitHub API if Indiekit is unavailable
* Gitea Activity Data
* Fetches commits and repos from self-hosted Gitea instance
*/
import EleventyFetch from "@11ty/eleventy-fetch";
const GITHUB_USERNAME = process.env.GITHUB_USERNAME || "";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
const GITEA_URL = process.env.GITEA_URL || "https://gitea.giersig.eu";
const GITEA_ORG = process.env.GITEA_ORG || "giersig.eu";
const GITEA_REPOS = (process.env.GITEA_REPOS || "indiekit-blog,indiekit-server").split(",").filter(Boolean);
// Fallback featured repos if Indiekit API unavailable (from env: comma-separated)
const FALLBACK_FEATURED_REPOS = process.env.GITHUB_FEATURED_REPOS?.split(",").filter(Boolean) || [];
/**
* Fetch from Indiekit's public GitHub API endpoint
*/
async function fetchFromIndiekit(endpoint) {
const urls = [
`${INDIEKIT_URL}/github/api/${endpoint}`,
`${INDIEKIT_URL}/githubapi/api/${endpoint}`,
];
for (const url of urls) {
try {
console.log(`[githubActivity] Fetching from Indiekit: ${url}`);
const data = await EleventyFetch(url, {
duration: "15m",
type: "json",
});
console.log(`[githubActivity] Indiekit ${endpoint} success via ${url}`);
return data;
} catch (error) {
console.log(
`[githubActivity] Indiekit API unavailable for ${endpoint} at ${url}: ${error.message}`
);
}
}
return null;
}
/**
* Fetch from GitHub API directly
*/
async function fetchFromGitHub(endpoint) {
const url = `https://api.github.com${endpoint}`;
const headers = {
Accept: "application/vnd.github.v3+json",
"User-Agent": "Eleventy-Site",
};
if (process.env.GITHUB_TOKEN) {
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
}
return await EleventyFetch(url, {
duration: "15m",
type: "json",
fetchOptions: { headers },
});
}
/**
* Truncate text with ellipsis
*/
function truncate(text, maxLength = 80) {
if (!text || text.length <= maxLength) return text || "";
return text.slice(0, maxLength - 1) + "...";
}
/**
* Extract commits from push events
*/
function extractCommits(events) {
if (!Array.isArray(events)) return [];
// Style commit keywords
const styleKeywords = [
"style", "css", "tailwind", "design", "layout", "responsive", "dark mode", "typography", "color", "palette", "theme"
];
return events
.filter((event) => event.type === "PushEvent")
.flatMap((event) =>
(event.payload?.commits || []).map((commit) => {
const msg = commit.message.split("\n")[0];
const lowerMsg = msg.toLowerCase();
const isStyle = styleKeywords.some((kw) => lowerMsg.includes(kw));
return {
sha: commit.sha.slice(0, 7),
message: truncate(msg),
url: `https://github.com/${event.repo.name}/commit/${commit.sha}`,
repo: event.repo.name,
repoUrl: `https://github.com/${event.repo.name}`,
date: event.created_at,
category: isStyle ? "style" : undefined,
};
})
)
.slice(0, 10);
}
/**
* Extract PRs/Issues from events
*/
function extractContributions(events) {
if (!Array.isArray(events)) return [];
return events
.filter(
(event) =>
(event.type === "PullRequestEvent" || event.type === "IssuesEvent") &&
event.payload?.action === "opened"
)
.map((event) => {
const item = event.payload.pull_request || event.payload.issue;
return {
type: event.type === "PullRequestEvent" ? "pr" : "issue",
title: truncate(item?.title),
url: item?.html_url,
repo: event.repo.name,
repoUrl: `https://github.com/${event.repo.name}`,
number: item?.number,
date: event.created_at,
};
})
.slice(0, 10);
}
/**
* Format starred repos
*/
function formatStarred(repos) {
if (!Array.isArray(repos)) return [];
return repos.map((repo) => ({
name: repo.full_name,
description: truncate(repo.description, 120),
url: repo.html_url,
stars: repo.stargazers_count,
language: repo.language,
topics: repo.topics?.slice(0, 5) || [],
}));
}
/**
* Fetch featured repos directly from GitHub (fallback)
*/
async function fetchFeaturedFromGitHub(repoList) {
const featured = [];
for (const repoFullName of repoList) {
try {
const repo = await fetchFromGitHub(`/repos/${repoFullName}`);
let commits = [];
try {
const commitsData = await fetchFromGitHub(
`/repos/${repoFullName}/commits?per_page=5`
);
commits = commitsData.map((c) => ({
sha: c.sha.slice(0, 7),
message: truncate(c.commit.message.split("\n")[0]),
url: c.html_url,
date: c.commit.author.date,
}));
} catch (e) {
console.log(`[githubActivity] Could not fetch commits for ${repoFullName}`);
}
featured.push({
fullName: repo.full_name,
name: repo.name,
description: repo.description,
url: repo.html_url,
stars: repo.stargazers_count,
forks: repo.forks_count,
language: repo.language,
isPrivate: repo.private,
commits,
});
} catch (error) {
console.log(`[githubActivity] Could not fetch ${repoFullName}: ${error.message}`);
}
}
return featured;
}
/**
* Fetch commits directly from user's recently pushed repos
* Fallback when events API doesn't include commit details
*/
async function fetchCommitsFromRepos(username, limit = 10) {
try {
const repos = await fetchFromGitHub(
`/users/${username}/repos?sort=pushed&per_page=5`
);
if (!Array.isArray(repos) || repos.length === 0) {
return [];
}
async function fetchGiteaCommits() {
const allCommits = [];
for (const repo of repos.slice(0, 5)) {
for (const repo of GITEA_REPOS) {
try {
const repoCommits = await fetchFromGitHub(
`/repos/${repo.full_name}/commits?per_page=5`
);
for (const c of repoCommits) {
const url = `${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${repo}/commits?limit=15`;
console.log(`[giteaActivity] Fetching commits: ${url}`);
const commits = await EleventyFetch(url, { duration: "15m", type: "json" });
for (const c of Array.isArray(commits) ? commits : []) {
const msg = (c.commit?.message || "").split("\n")[0];
allCommits.push({
sha: c.sha.slice(0, 7),
message: truncate(c.commit?.message?.split("\n")[0]),
message: truncate(msg),
url: c.html_url,
repo: repo.full_name,
repoUrl: repo.html_url,
date: c.commit?.author?.date,
repo: `${GITEA_ORG}/${repo}`,
repoUrl: `${GITEA_URL}/${GITEA_ORG}/${repo}`,
date: c.created || c.commit?.author?.date,
});
}
} catch {
// Skip repos we can't access
} catch (e) {
console.log(`[giteaActivity] Commits fetch failed for ${repo}: ${e.message}`);
}
}
// Sort by date and limit
return allCommits
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, limit);
} catch (error) {
console.log(`[githubActivity] Could not fetch commits from repos: ${error.message}`);
return allCommits.sort((a, b) => new Date(b.date) - new Date(a.date)).slice(0, 10);
}
async function fetchGiteaFeatured() {
try {
const url = `${GITEA_URL}/api/v1/orgs/${GITEA_ORG}/repos?limit=10&sort=newest`;
const repos = await EleventyFetch(url, { duration: "15m", type: "json" });
return (Array.isArray(repos) ? repos : [])
.filter((r) => !r.fork && !r.private)
.slice(0, 5)
.map((r) => ({
fullName: r.full_name,
name: r.name,
description: r.description,
url: r.html_url,
stars: r.stars_count || r.stargazers_count || 0,
forks: r.forks_count || 0,
language: r.language,
}));
} catch (e) {
console.log(`[giteaActivity] Featured fetch failed: ${e.message}`);
return [];
}
}
export default async function () {
try {
console.log("[githubActivity] Fetching GitHub data...");
console.log("[giteaActivity] Fetching Gitea data...");
// Try Indiekit public API first
const [indiekitStars, indiekitCommits, indiekitContributions, indiekitActivity, indiekitFeatured] =
await Promise.all([
fetchFromIndiekit("stars"),
fetchFromIndiekit("commits"),
fetchFromIndiekit("contributions"),
fetchFromIndiekit("activity"),
fetchFromIndiekit("featured"),
const [commits, featured] = await Promise.all([
fetchGiteaCommits(),
fetchGiteaFeatured(),
]);
// Check if Indiekit API is available
const hasIndiekitData =
indiekitStars?.stars ||
indiekitCommits?.commits ||
indiekitFeatured?.featured;
if (hasIndiekitData) {
console.log("[githubActivity] Using Indiekit API data");
return {
stars: indiekitStars?.stars || [],
commits: indiekitCommits?.commits || [],
contributions: indiekitContributions?.contributions || [],
activity: indiekitActivity?.activity || [],
featured: indiekitFeatured?.featured || [],
source: "indiekit",
};
}
// Fallback to direct GitHub API
console.log("[githubActivity] Falling back to GitHub API");
const [events, starred, featured] = await Promise.all([
fetchFromGitHub(`/users/${GITHUB_USERNAME}/events/public?per_page=50`),
fetchFromGitHub(`/users/${GITHUB_USERNAME}/starred?per_page=20&sort=created`),
fetchFeaturedFromGitHub(FALLBACK_FEATURED_REPOS),
]);
// Try to extract commits from events first
let commits = extractCommits(events || []);
// If events API didn't have commits, fetch directly from repos
if (commits.length === 0 && GITHUB_USERNAME) {
console.log("[githubActivity] Events API returned no commits, fetching from repos");
commits = await fetchCommitsFromRepos(GITHUB_USERNAME, 10);
}
return {
stars: formatStarred(starred || []),
commits,
contributions: extractContributions(events || []),
stars: [],
contributions: [],
featured,
source: "github",
source: "gitea",
};
} catch (error) {
console.error("[githubActivity] Error:", error.message);
console.error("[giteaActivity] Error:", error.message);
return {
stars: [],
commits: [],
stars: [],
contributions: [],
featured: [],
source: "error",

View File

@@ -1,48 +1,41 @@
/**
* GitHub Repos Data
* Fetches public repositories from GitHub API
* Gitea Repos Data
* Fetches public repositories from Gitea org API
*/
import { cachedFetch } from "../lib/data-fetch.js";
export default async function () {
const username = process.env.GITHUB_USERNAME || "";
const GITEA_URL = process.env.GITEA_URL || "https://gitea.giersig.eu";
const GITEA_ORG = process.env.GITEA_ORG || "giersig.eu";
export default async function () {
try {
// Fetch public repos, sorted by updated date
const url = `https://api.github.com/users/${username}/repos?sort=updated&per_page=10&type=owner`;
const url = `${GITEA_URL}/api/v1/orgs/${GITEA_ORG}/repos?limit=10&sort=newest`;
const repos = await cachedFetch(url, {
duration: "1h", // Cache for 1 hour
duration: "1h",
type: "json",
fetchOptions: {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "Eleventy-Site",
},
},
});
// Filter and transform repos
return repos
.filter((repo) => !repo.fork && !repo.private) // Exclude forks and private repos
.filter((repo) => !repo.fork && !repo.private)
.map((repo) => ({
name: repo.name,
full_name: repo.full_name,
description: repo.description,
html_url: repo.html_url,
homepage: repo.homepage,
homepage: repo.website || repo.homepage,
language: repo.language,
stargazers_count: repo.stargazers_count,
forks_count: repo.forks_count,
open_issues_count: repo.open_issues_count,
stargazers_count: repo.stars_count || repo.stargazers_count || 0,
forks_count: repo.forks_count || 0,
open_issues_count: repo.open_issues_count || 0,
topics: repo.topics || [],
updated_at: repo.updated_at,
created_at: repo.created_at,
updated_at: repo.updated,
created_at: repo.created,
}))
.slice(0, 10); // Limit to 10 repos
.slice(0, 10);
} catch (error) {
console.error("Error fetching GitHub repos:", error.message);
console.error("Error fetching Gitea repos:", error.message);
return [];
}
}

View File

@@ -112,6 +112,13 @@ export default {
},
},
// Gitea configuration
gitea: {
url: process.env.GITEA_URL || "https://gitea.giersig.eu",
org: process.env.GITEA_ORG || "giersig.eu",
repos: (process.env.GITEA_REPOS || "indiekit-blog,indiekit-server").split(",").filter(Boolean),
},
// Webmentions configuration
webmentions: {
domain: process.env.SITE_URL?.replace("https://", "").replace("http://", "") || "example.com",

View File

@@ -14,7 +14,7 @@
{# Resolve widget title #}
{% if widget.type == "search" %}{% set widgetTitle = "Search" %}
{% elif widget.type == "social-activity" %}{% set widgetTitle = "Social Activity" %}
{% elif widget.type == "github-repos" %}{% set widgetTitle = "GitHub" %}
{% elif widget.type == "github-repos" %}{% set widgetTitle = "Gitea" %}
{% elif widget.type == "funkwhale" %}{% set widgetTitle = "Listening" %}
{% elif widget.type == "recent-posts" %}{% set widgetTitle = "Recent Posts" %}
{% elif widget.type == "blogroll" %}{% set widgetTitle = "Blogroll" %}

View File

@@ -10,7 +10,7 @@
{# Resolve widget title #}
{% if widget.type == "search" %}{% set widgetTitle = "Search" %}
{% elif widget.type == "social-activity" %}{% set widgetTitle = "Social Activity" %}
{% elif widget.type == "github-repos" %}{% set widgetTitle = "GitHub" %}
{% elif widget.type == "github-repos" %}{% set widgetTitle = "Gitea" %}
{% elif widget.type == "funkwhale" %}{% set widgetTitle = "Listening" %}
{% elif widget.type == "recent-posts" %}{% set widgetTitle = "Recent Posts" %}
{% elif widget.type == "blogroll" %}{% set widgetTitle = "Blogroll" %}

View File

@@ -12,7 +12,7 @@
{# Resolve widget title #}
{% if widget.type == "search" %}{% set widgetTitle = "Search" %}
{% elif widget.type == "social-activity" %}{% set widgetTitle = "Social Activity" %}
{% elif widget.type == "github-repos" %}{% set widgetTitle = "GitHub" %}
{% elif widget.type == "github-repos" %}{% set widgetTitle = "Gitea" %}
{% elif widget.type == "funkwhale" %}{% set widgetTitle = "Listening" %}
{% elif widget.type == "recent-posts" %}{% set widgetTitle = "Recent Posts" %}
{% elif widget.type == "blogroll" %}{% set widgetTitle = "Blogroll" %}
@@ -206,7 +206,7 @@
<div class="widget-collapsible mb-4" x-data="{ open: localStorage.getItem('{{ widgetKey }}') !== null ? localStorage.getItem('{{ widgetKey }}') === 'true' : false }">
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden border-l-[3px] border-l-surface-400 dark:border-l-surface-500">
<button class="widget-header w-full p-4" @click="open = !open; localStorage.setItem('{{ widgetKey }}', open)" :aria-expanded="open ? 'true' : 'false'">
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("github", "w-5 h-5 text-surface-800 dark:text-surface-200") }} GitHub</h3>
<h3 class="widget-title font-bold text-lg flex items-center gap-2">{{ icon("github", "w-5 h-5 text-surface-800 dark:text-surface-200") }} Gitea</h3>
<svg class="widget-chevron" :class="open && 'rotate-180'" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" x-cloak>

View File

@@ -1,30 +1,20 @@
{# GitHub Activity Widget - Tabbed Commits/Repos/Featured/PRs with live API data #}
{# Gitea Activity Widget - Tabbed Commits/Repos/Featured/PRs #}
<is-land on:visible>
{% set ghFallbackCommits = githubActivity.commits if githubActivity and githubActivity.commits else [] %}
{% set ghFallbackFeatured = githubActivity.featured if githubActivity and githubActivity.featured else [] %}
{% set ghFallbackContributions = githubActivity.contributions if githubActivity and githubActivity.contributions else [] %}
{% set ghFallbackRepos = githubRepos if githubRepos else [] %}
{% set id = homepageConfig.identity if (homepageConfig and homepageConfig.identity) else {} %}
{% set socialLinks = id.social if (id.social is defined) else site.social %}
{% set githubProfileUrl = "" %}
{% for link in socialLinks %}
{% if not githubProfileUrl and (link.icon == "github" or "github.com/" in link.url) %}
{% set githubProfileUrl = link.url %}
{% endif %}
{% endfor %}
{% if not githubProfileUrl and site.feeds.github %}
{% set githubProfileUrl = "https://github.com/" + site.feeds.github %}
{% endif %}
<div class="widget" x-data="githubWidget('{{ site.feeds.github }}', '{{ githubProfileUrl }}')" x-init="init()">
{% set giteaProfileUrl = site.gitea.url + "/" + site.gitea.org %}
<div class="widget" x-data="giteaWidget('{{ site.gitea.url }}', '{{ site.gitea.org }}', '{{ site.gitea.repos | join(",") }}')" x-init="init()">
<h3 class="widget-title flex items-center gap-2">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path>
</svg>
GitHub
Gitea
</h3>
{# Tab buttons — order: Commits, Repos, Featured, PRs #}
<div class="flex gap-1 mb-4 border-b border-surface-200 dark:border-surface-700" role="tablist" aria-label="GitHub activity">
<div class="flex gap-1 mb-4 border-b border-surface-200 dark:border-surface-700" role="tablist" aria-label="Gitea activity">
<button
@click="activeTab = 'commits'"
:class="activeTab === 'commits' ? 'border-b-2 border-accent-500 text-accent-700 dark:text-accent-300' : 'text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300'"
@@ -151,11 +141,9 @@
<a :href="item.url" target="_blank" rel="noopener" class="block group">
<div class="flex items-center gap-2">
<span
class="flex-shrink-0 w-4 h-4 rounded-full flex items-center justify-center"
:class="item.type === 'pr' ? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400' : 'bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-400'"
class="flex-shrink-0 w-4 h-4 rounded-full flex items-center justify-center bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400"
>
<svg x-show="item.type === 'pr'" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
<svg x-show="item.type === 'issue'" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke-width="2"/><line x1="12" y1="8" x2="12" y2="12" stroke-width="2"/><line x1="12" y1="16" x2="12.01" y2="16" stroke-width="2"/></svg>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</span>
<span class="text-sm text-surface-700 dark:text-surface-300 group-hover:text-surface-900 dark:group-hover:text-surface-100 transition-colors truncate" x-text="item.title"></span>
</div>
@@ -168,18 +156,16 @@
</li>
</template>
</ul>
<div x-show="contributions.length === 0" class="text-sm text-surface-600 dark:text-surface-400 py-2">No recent PRs or issues.</div>
<div x-show="contributions.length === 0" class="text-sm text-surface-600 dark:text-surface-400 py-2">No recent PRs.</div>
</div>
</div>
{# Footer link #}
{% if githubProfileUrl %}
<a href="{{ githubProfileUrl }}" target="_blank" rel="noopener" class="text-sm text-accent-700 dark:text-accent-300 hover:underline mt-3 inline-flex items-center gap-1">
View on GitHub
<a href="{{ giteaProfileUrl }}" target="_blank" rel="noopener" class="text-sm text-accent-700 dark:text-accent-300 hover:underline mt-3 inline-flex items-center gap-1">
View on Gitea
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
{% endif %}
</div>
<script>
@@ -190,7 +176,9 @@ const githubFallbackData = {
repos: {{ ghFallbackRepos | dump | safe }},
};
function githubWidget(username, profileUrl) {
function giteaWidget(giteaUrl, giteaOrg, reposStr) {
const repoList = reposStr.split(',').filter(Boolean);
return {
activeTab: 'commits',
loading: true,
@@ -199,108 +187,98 @@ function githubWidget(username, profileUrl) {
featured: [],
contributions: [],
sanitizeRepos(repos) {
if (!Array.isArray(repos)) return [];
return repos.filter((repo) => !repo.fork && !repo.private);
},
deriveUsernameFromCommits(commits) {
if (!Array.isArray(commits) || commits.length === 0) return '';
const firstRepo = commits.find((item) => item && item.repo)?.repo || '';
return firstRepo.includes('/') ? firstRepo.split('/')[0] : '';
},
deriveUsernameFromProfile(url) {
if (!url || typeof url !== 'string') return '';
const match = url.match(/github\.com\/([^/?#]+)/i);
return match ? match[1] : '';
},
async fetchJson(paths) {
for (const path of paths) {
async fetchJson(url) {
try {
const response = await fetch(path);
if (response.ok) {
return {
ok: true,
data: await response.json(),
};
}
} catch {
// Try next candidate path.
}
}
return {
ok: false,
data: null,
};
},
async fetchReposForUser(user) {
if (!user) return [];
try {
const response = await fetch(
'https://api.github.com/users/' + user + '/repos?sort=updated&per_page=10&type=owner',
{
headers: { Accept: 'application/vnd.github.v3+json' },
}
);
if (!response.ok) return [];
return this.sanitizeRepos(await response.json());
} catch {
return [];
}
const r = await fetch(url);
if (r.ok) return await r.json();
} catch {}
return null;
},
async init() {
this.commits = Array.isArray(githubFallbackData.commits) ? githubFallbackData.commits : [];
this.featured = Array.isArray(githubFallbackData.featured) ? githubFallbackData.featured : [];
this.contributions = Array.isArray(githubFallbackData.contributions) ? githubFallbackData.contributions : [];
this.repos = this.sanitizeRepos(githubFallbackData.repos);
this.repos = (githubFallbackData.repos || []).filter((r) => !r.fork && !r.private);
const hasFallbackData =
this.commits.length > 0 ||
this.featured.length > 0 ||
this.contributions.length > 0 ||
this.repos.length > 0;
this.loading = !hasFallbackData;
try {
const [commitsRes, featuredRes, contribRes] = await Promise.all([
this.fetchJson(['/github/api/commits', '/githubapi/api/commits']),
this.fetchJson(['/github/api/featured', '/githubapi/api/featured']),
this.fetchJson(['/github/api/contributions', '/githubapi/api/contributions']),
]);
if (commitsRes.ok) {
this.commits = commitsRes.data?.commits || [];
}
if (featuredRes.ok) {
this.featured = featuredRes.data?.featured || [];
}
if (contribRes.ok) {
this.contributions = contribRes.data?.contributions || [];
// Fetch commits from all repos
const commitArrays = await Promise.all(
repoList.map((repo) =>
this.fetchJson(`${giteaUrl}/api/v1/repos/${giteaOrg}/${repo}/commits?limit=10`)
)
);
const allCommits = commitArrays.flatMap((commits, i) => {
if (!Array.isArray(commits)) return [];
return commits.map((c) => ({
sha: c.sha.slice(0, 7),
message: (c.commit?.message || '').split('\n')[0],
url: c.html_url,
repo: `${giteaOrg}/${repoList[i]}`,
date: c.created || c.commit?.author?.date,
}));
});
if (allCommits.length > 0) {
this.commits = allCommits
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 10);
}
let resolvedUsername = this.deriveUsernameFromProfile(profileUrl);
if (!resolvedUsername) {
resolvedUsername = username;
}
if (!resolvedUsername) {
resolvedUsername = this.deriveUsernameFromCommits(this.commits);
// Fetch repos
const repos = await this.fetchJson(`${giteaUrl}/api/v1/orgs/${giteaOrg}/repos?limit=10&sort=newest`);
if (Array.isArray(repos)) {
const filtered = repos.filter((r) => !r.fork && !r.private);
this.repos = filtered.map((r) => ({
name: r.name,
full_name: r.full_name,
description: r.description,
html_url: r.html_url,
language: r.language,
stargazers_count: r.stars_count || r.stargazers_count || 0,
updated_at: r.updated,
}));
this.featured = filtered.slice(0, 5).map((r) => ({
fullName: r.full_name,
name: r.name,
description: r.description,
url: r.html_url,
stars: r.stars_count || r.stargazers_count || 0,
forks: r.forks_count || 0,
language: r.language,
}));
}
const repos = await this.fetchReposForUser(resolvedUsername);
if (repos.length > 0 || this.repos.length === 0) {
this.repos = repos;
// Fetch PRs
const prArrays = await Promise.all(
repoList.map((repo) =>
this.fetchJson(`${giteaUrl}/api/v1/repos/${giteaOrg}/${repo}/pulls?state=closed&limit=5&type=pulls`)
)
);
const allPRs = prArrays.flatMap((prs, i) => {
if (!Array.isArray(prs)) return [];
return prs.map((pr) => ({
type: 'pr',
title: pr.title,
url: pr.html_url,
repo: `${giteaOrg}/${repoList[i]}`,
number: pr.number,
date: pr.merged_at || pr.closed_at || pr.created_at,
}));
});
if (allPRs.length > 0) {
this.contributions = allPRs
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 10);
}
} catch (err) {
console.error('GitHub widget error:', err);
console.error('Gitea widget error:', err);
} finally {
this.loading = false;
}

View File

@@ -106,6 +106,20 @@ withSidebar: false
</div>
<script>
const GITEA_URL = '{{ site.gitea.url }}';
const GITEA_ORG = '{{ site.gitea.org }}';
const GITEA_REPOS = {{ site.gitea.repos | dump | safe }};
function categorizeCommit(message) {
const lower = (message || '').toLowerCase();
if (/^feat(\(.+\))?!?:/.test(lower)) return 'features';
if (/^fix(\(.+\))?!?:/.test(lower)) return 'fixes';
if (/^docs?(\(.+\))?!?:/.test(lower)) return 'documentation';
if (/^(chore|build|ci|style)(\(.+\))?!?:/.test(lower)) return 'chores';
if (/^(refactor|perf|test|a11y)(\(.+\))?!?:/.test(lower)) return 'refactor';
return 'chores';
}
function changelogApp() {
return {
activeTab: 'all',
@@ -152,12 +166,53 @@ function changelogApp() {
async fetchChangelog(days) {
try {
const response = await fetch('/github/api/changelog?days=' + days);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
this.commits = data.commits || [];
this.categories = data.categories || {};
this.currentDays = data.days;
const since = days === 'all'
? null
: new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
const allCommits = [];
await Promise.all(GITEA_REPOS.map(async (repo) => {
let page = 1;
const limit = 50;
while (true) {
let url = `${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${repo}/commits?limit=${limit}&page=${page}`;
if (since) url += `&since=${since}`;
const r = await fetch(url);
if (!r.ok) break;
const commits = await r.json();
if (!Array.isArray(commits) || commits.length === 0) break;
for (const c of commits) {
const lines = (c.commit?.message || '').split('\n');
const title = lines[0];
const body = lines.slice(1).join('\n').trim();
allCommits.push({
sha: c.sha.slice(0, 7),
fullSha: c.sha,
title,
body: body || null,
url: c.html_url,
repoUrl: `${GITEA_URL}/${GITEA_ORG}/${repo}`,
repoName: repo,
date: c.created || c.commit?.author?.date,
author: c.commit?.author?.name || '',
commitCategory: categorizeCommit(title),
});
}
if (commits.length < limit) break;
page++;
}
}));
allCommits.sort((a, b) => new Date(b.date) - new Date(a.date));
const categories = {};
for (const c of allCommits) {
categories[c.commitCategory] = (categories[c.commitCategory] || 0) + 1;
}
this.commits = allCommits;
this.categories = categories;
this.currentDays = days;
} catch (err) {
console.error('Changelog error:', err);
} finally {