Initial commit: personal homepage with live Harbor registry
build / image (push) Failing after 15s

- Linktree-style homepage with photo, social links, and self-hosted
  services (container registry, Git server)
- Registry pages rendered on demand from Harbor via a live content
  collection, cached in memory (60s fresh, 10min stale-while-revalidate)
- Pinned registry images (PINNED_IMAGES) shown first with a star
- Node standalone server with security headers; self-contained server
  build so the Docker image ships no node_modules
- Gitea workflow building multi-arch images for Flux

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 10:23:02 +02:00
co-authored by Claude Opus 5.5
commit 7b670010b1
33 changed files with 5004 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.astro
.env
.env.*
!.env.example
+15
View File
@@ -0,0 +1,15 @@
# Public URL of this site (canonical URLs, sitemap later on). Build time.
SITE_URL=https://example.com
# Harbor instance to showcase. Read at runtime by the server; changes need a restart, not a rebuild.
HARBOR_URL=https://registry.example.com
# Host shown in `docker pull` commands (defaults to the host of HARBOR_URL)
# HARBOR_REGISTRY_HOST=registry.example.com
# Comma-separated project allow-list (defaults to all public projects)
# HARBOR_PROJECTS=library,tools
# Optional read-only robot account; not needed for public projects
# HARBOR_USERNAME=robot$showcase
# HARBOR_PASSWORD=
# Host port for docker compose (default 8080)
# PORT=8080
+59
View File
@@ -0,0 +1,59 @@
name: build
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
env:
IMAGE: registry.traberph.de/public/homepage
# Baked in at build time (canonical URLs); override with a repository variable.
SITE_URL: ${{ vars.SITE_URL || 'https://traberph.de' }}
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# Tag scheme for Flux image automation: <UTC yyyymmddHHMMSS>-<short sha>,
# e.g. 20260926154233-1a2b3c4. Timestamps sort numerically, see README.
- name: Compute tags
id: tags
run: |
ts="$(date -u +%Y%m%d%H%M%S)"
sha="$(git rev-parse --short=7 HEAD)"
tags="${IMAGE}:${ts}-${sha}"
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
tags="${tags},${IMAGE}:${GITHUB_REF_NAME}"
else
tags="${tags},${IMAGE}:latest"
fi
echo "tags=${tags}" >> "$GITHUB_OUTPUT"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "Tags: ${tags}"
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: registry.traberph.de
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.tags.outputs.tags }}
# Harbor settings are runtime env vars, so only the site URL is a build arg.
build-args: |
SITE_URL=${{ env.SITE_URL }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ steps.tags.outputs.sha }}
org.opencontainers.image.created=${{ steps.tags.outputs.created }}
+24
View File
@@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
+34
View File
@@ -0,0 +1,34 @@
## Development
When starting the dev server, use background mode:
```
astro dev --background
```
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
## Documentation
Full documentation: https://docs.astro.build
Prefer the `astro-docs` MCP server (`search_astro_docs`) for Astro questions. It searches the current docs, so use it before relying on memory: APIs change between majors (this project is on Astro 7). Fall back to fetching the links below if the MCP is unavailable.
Consult these guides before working on related tasks:
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
## Project notes
- Node adapter (standalone), started via `server.mjs` (adds security headers). Pages are prerendered by default; `/registry/…` and `404` use `prerender = false`.
- Harbor data is live: `src/live.config.ts` + live loader `src/loaders/harbor.ts`, queried with `getLiveCollection()`/`getLiveEntry()`. Rendered pages are cached with `Astro.cache` (`memoryCache()`; `REGISTRY_CACHE` in `src/consts.ts`).
- Harbor config comes from `astro:env/server` (all `secret`, so read at runtime, never inlined). Anonymous Harbor returns 401 for single-repo GETs; look repos up via the listing with `?q=name=…`.
- Runtime deps must be listed in `vite.ssr.noExternal` (the Docker image ships no `node_modules`).
- Zod comes from `astro/zod` (Zod 4).
- `astro check` needs TypeScript 6 (TS 7 is unsupported), so keep `typescript@^6`.
- Run `pnpm check && pnpm build` before finishing a change.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+24
View File
@@ -0,0 +1,24 @@
# syntax=docker/dockerfile:1.10
# The build output is platform-independent, so build natively (no QEMU) for multi-arch images.
FROM --platform=$BUILDPLATFORM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
ENV ASTRO_TELEMETRY_DISABLED=1
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY . .
# Only the canonical site URL is baked in; Harbor settings are read at runtime.
ARG SITE_URL
RUN pnpm build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production HOST=0.0.0.0 PORT=8080
# The server build is self-contained (see `vite.ssr.noExternal`), so no node_modules.
COPY --from=build /app/dist ./dist
COPY package.json server.mjs ./
USER node
EXPOSE 8080
CMD ["node", "server.mjs"]
+56
View File
@@ -0,0 +1,56 @@
# homepage
Personal site built with [Astro 7](https://docs.astro.build). Currently a read-only showcase of the
public images on a Harbor registry; built to grow into a blog/portfolio with more backends (e.g. Gitea).
## How it works
- **Static where possible, live where it matters.** The home and 404 pages are prerendered. The
registry pages (`/registry/…`) render on request through the Node adapter, reading Harbor via a
[live content collection](https://docs.astro.build/en/guides/content-collections/#live-content-collections)
(`src/live.config.ts`, loader in `src/loaders/harbor.ts`). No credentials or API calls reach the browser.
- **Cached.** Rendered registry pages are kept in Astro's in-memory route cache for 60 s and served stale
for up to 10 min while a fresh copy renders in the background (`REGISTRY_CACHE` in `src/consts.ts`).
The listing also reuses a repository's artifacts for up to 5 min while its `update_time` is unchanged.
- **No rebuilds for new images.** Pushing to Harbor shows up within about a minute. Rebuild only after
code changes.
- **Harbor unreachable?** The listing shows a notice and detail pages return `503`; neither is cached.
- Repository descriptions are rendered as Markdown with raw HTML escaped and unsafe link schemes removed.
## Develop
```sh
cp .env.example .env # set HARBOR_URL
pnpm install
pnpm dev # http://localhost:4321
pnpm check # type-check
pnpm build # -> dist/
pnpm start # run the production server (reads .env)
```
Without `HARBOR_URL` the registry is simply empty.
## Deploy (Docker Compose)
```sh
cp .env.example .env # set HARBOR_URL etc.
docker compose up -d --build
```
Harbor settings are passed to the container at runtime, so changing them needs a restart
(`docker compose up -d`), not a rebuild. Served on `${PORT:-8080}`.
## Container (plain Docker)
```sh
docker build -t homepage --build-arg SITE_URL=https://example.com .
docker run -p 8080:8080 \
-e HARBOR_URL=https://registry.example.com \
homepage
# optional robot account for non-public projects:
# -e HARBOR_USERNAME='robot$showcase' -e HARBOR_PASSWORD=...
```
A small Node server (`server.mjs`) runs Astro's standalone handler, adds security headers, and serves
hashed `/_astro/` assets with long-lived caching. The server build is self-contained, so the image
ships no `node_modules`.
+28
View File
@@ -0,0 +1,28 @@
// @ts-check
import { defineConfig, envField, memoryCache } from 'astro/config';
import node from '@astrojs/node';
// https://astro.build/config
export default defineConfig({
// Used for canonical URLs (and the sitemap/RSS once the blog lands).
// Note: .env is not loaded into process.env for this file; pass it via the environment.
site: process.env.SITE_URL,
trailingSlash: 'always',
prefetch: { defaultStrategy: 'hover', prefetchAll: true },
// Static pages stay prerendered; the registry pages opt into on-demand rendering.
adapter: node({ mode: 'standalone' }),
// No page reads query params, so ignore them in cache keys (`?x=random` can't bypass the cache).
cache: { provider: memoryCache({ query: { include: [] } }) },
// Bundle runtime deps into the server build so the image needs no node_modules.
vite: { ssr: { noExternal: ['marked'] } },
env: {
// All `secret` so they are read from the server environment at runtime, never inlined at build.
schema: {
HARBOR_URL: envField.string({ context: 'server', access: 'secret', optional: true, url: true }),
HARBOR_REGISTRY_HOST: envField.string({ context: 'server', access: 'secret', optional: true }),
HARBOR_PROJECTS: envField.string({ context: 'server', access: 'secret', optional: true }),
HARBOR_USERNAME: envField.string({ context: 'server', access: 'secret', optional: true }),
HARBOR_PASSWORD: envField.string({ context: 'server', access: 'secret', optional: true }),
},
},
});
+30
View File
@@ -0,0 +1,30 @@
# Registry pages are rendered on request from live Harbor data (cached ~1 min),
# so publishing a new image needs no rebuild. Rebuild only after code changes:
# docker compose up -d --build
# Config comes from .env (see .env.example).
services:
web:
build:
context: .
args:
SITE_URL: ${SITE_URL:-}
image: homepage:latest
restart: unless-stopped
ports:
- "${PORT:-8080}:8080"
environment:
HARBOR_URL: ${HARBOR_URL:?set HARBOR_URL in .env}
HARBOR_REGISTRY_HOST: ${HARBOR_REGISTRY_HOST:-}
HARBOR_PROJECTS: ${HARBOR_PROJECTS:-}
HARBOR_USERNAME: ${HARBOR_USERNAME:-}
# Only needed for non-public projects; empty is fine.
HARBOR_PASSWORD: ${HARBOR_PASSWORD:-}
read_only: true
tmpfs:
- /tmp
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8080/"]
interval: 30s
timeout: 3s
retries: 3
+32
View File
@@ -0,0 +1,32 @@
{
"name": "homepage",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"start": "node --env-file-if-exists=.env server.mjs",
"check": "astro check",
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^11.1.6",
"astro": "^7.3.5",
"marked": "^18.0.14",
"sharp": "^0.35.5"
},
"allowScripts": {
"esbuild": true
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"@types/node": "^22.20.4",
"typescript": "^6.0.3"
},
"private": true,
"packageManager": "pnpm@11.26.0"
}
+3489
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
esbuild: true
sharp: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960">
<!-- "landscape" from Material Symbols Outlined (Apache-2.0). White on dark browser UI, dark on light. -->
<style>
path { fill: #1c1917; }
@media (prefers-color-scheme: dark) { path { fill: #ffffff; } }
</style>
<path d="m40-240 240-320 180 240h300L560-586 460-454l-50-66 150-200 360 480H40Zm521-80Zm-361 0h160l-80-107-80 107Zm0 0h160-160Z"/>
</svg>

After

Width:  |  Height:  |  Size: 424 B

+27
View File
@@ -0,0 +1,27 @@
// Production entry: Astro's standalone Node handler (static files + on-demand pages)
// wrapped to add the security headers nginx used to set.
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
import http from 'node:http';
const { handler } = await import('./dist/server/entry.mjs');
const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'X-Frame-Options': 'DENY',
};
const port = Number(process.env.PORT ?? 8080);
const host = process.env.HOST ?? '0.0.0.0';
const server = http.createServer((req, res) => {
for (const [name, value] of Object.entries(SECURITY_HEADERS)) res.setHeader(name, value);
handler(req, res);
});
server.listen(port, host, () => console.log(`Listening on http://${host}:${port}`));
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => server.close(() => process.exit(0)));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

+50
View File
@@ -0,0 +1,50 @@
---
interface Props {
text: string;
label?: string;
}
const { text, label = 'Copy' } = Astro.props;
---
<copy-button data-text={text}>
<button type="button" aria-label={`${label}: ${text}`} hidden>{label}</button>
</copy-button>
<script>
// Progressive enhancement: the button only appears when the Clipboard API is available.
class CopyButton extends HTMLElement {
connectedCallback() {
const button = this.querySelector('button');
const text = this.dataset.text;
if (!button || !text || !navigator.clipboard) return;
button.hidden = false;
const original = button.textContent;
button.addEventListener('click', async () => {
await navigator.clipboard.writeText(text);
button.textContent = 'Copied';
setTimeout(() => (button.textContent = original), 1500);
});
}
}
customElements.define('copy-button', CopyButton);
</script>
<style>
button {
font: inherit;
font-size: 0.8rem;
padding: 0.2rem 0.6rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
</style>
+31
View File
@@ -0,0 +1,31 @@
---
import CopyButton from './CopyButton.astro';
interface Props {
image: string;
}
const command = `docker pull ${Astro.props.image}`;
---
<div class="pull">
<code>{command}</code>
<CopyButton text={command} />
</div>
<style>
.pull {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap);
padding: 0.5rem 0.75rem;
background: var(--code-bg);
border-radius: var(--radius);
}
code {
overflow-x: auto;
white-space: nowrap;
}
</style>
+45
View File
@@ -0,0 +1,45 @@
---
import type { Artifact } from '../loaders/harbor';
interface Props {
scan: Artifact['vulnerabilities'];
}
const { scan } = Astro.props;
const level = scan ? (scan.total === 0 ? 'none' : scan.severity.toLowerCase()) : undefined;
const breakdown = scan
? Object.entries(scan.bySeverity)
.filter(([, n]) => n > 0)
.map(([sev, n]) => `${n} ${sev}`)
.join(', ')
: '';
---
{
scan ? (
<span class="badge" data-level={level} title={breakdown || 'No known vulnerabilities'}>
{scan.total === 0 ? 'No CVEs' : `${scan.total} CVEs (${scan.fixable} fixable)`}
</span>
) : (
<span class="badge">Not scanned</span>
)
}
<style>
.badge {
--c: var(--muted);
display: inline-block;
font-size: 0.75rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
border: 1px solid var(--c);
color: var(--c);
white-space: nowrap;
}
[data-level='critical'] { --c: var(--sev-critical); }
[data-level='high'] { --c: var(--sev-high); }
[data-level='medium'] { --c: var(--sev-medium); }
[data-level='low'] { --c: var(--sev-low); }
[data-level='none'] { --c: var(--sev-none); }
</style>
+10
View File
@@ -0,0 +1,10 @@
// Site-wide constants. Adjust to taste.
export const SITE_TITLE = 'traberph';
export const SITE_DESCRIPTION = 'Public container images and projects.';
// How long rendered registry pages are served from the in-memory cache (seconds).
// Stale pages are served for up to `swr` more while a fresh copy renders in the background.
export const REGISTRY_CACHE = { maxAge: 60, swr: 600 };
// Registry images ("project/name") shown first with a star, in this order.
export const PINNED_IMAGES: string[] = ['public/sish-client'];
+81
View File
@@ -0,0 +1,81 @@
---
import { SITE_DESCRIPTION, SITE_TITLE } from '../consts';
import '../styles/global.css';
interface Props {
title: string;
description?: string;
}
const { title, description = SITE_DESCRIPTION } = Astro.props;
const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefined;
const path = Astro.url.pathname;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content={Astro.generator} />
<title>{title === SITE_TITLE ? title : `${title} · ${SITE_TITLE}`}</title>
<meta name="description" content={description} />
{canonical && <link rel="canonical" href={canonical} />}
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
</head>
<body>
<header>
<nav aria-label="Main">
<a href="/" class="brand">{SITE_TITLE}</a>
<a href="/" aria-current={path === '/' ? 'page' : undefined}>Home</a>
<a href="/registry/" aria-current={path.startsWith('/registry') ? 'page' : undefined}>Registry</a>
</nav>
</header>
<main>
<slot />
</main>
</body>
</html>
<style>
header,
main {
max-width: var(--max-width);
margin-inline: auto;
padding-inline: var(--gap);
}
header {
border-bottom: 1px solid var(--border);
}
nav {
display: flex;
gap: 1.5rem;
align-items: center;
padding-block: 1rem;
}
nav a {
color: var(--muted);
text-decoration: none;
}
nav a:hover,
nav a[aria-current='page'] {
color: var(--text);
}
.brand {
font-weight: 600;
color: var(--text);
margin-right: auto;
}
main {
padding-block: 2rem 4rem;
}
</style>
+24
View File
@@ -0,0 +1,24 @@
const bytes = new Intl.NumberFormat('en', { maximumFractionDigits: 1 });
const date = new Intl.DateTimeFormat('en', { dateStyle: 'medium' });
export function formatBytes(n: number): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${bytes.format(n)} ${units[i]}`;
}
export function formatDate(d: Date): string {
return date.format(d);
}
export function shortDigest(digest: string): string {
return digest.replace(/^sha256:/, '').slice(0, 12);
}
export function formatPlatform(p: { os: string; architecture: string; variant?: string }): string {
return [p.os, p.architecture, p.variant].filter(Boolean).join('/');
}
+31
View File
@@ -0,0 +1,31 @@
import { Marked } from 'marked';
// Harbor repository descriptions are Markdown written by anyone with push rights to
// the project, so raw HTML is escaped and only safe link/image URL schemes pass through.
const SAFE_SCHEME = /^(https?|mailto):/i;
function isSafeUrl(href: string): boolean {
// Browsers ignore whitespace/control chars inside schemes ("java\tscript:").
const url = href.replace(/[\u0000-\u0020]/g, '');
// Anything that might carry a scheme (incl. entity-encoded colons) must be an allowed one.
const beforePath = url.split(/[/?#]/, 1)[0];
return !/[:&]/.test(beforePath) || SAFE_SCHEME.test(url);
}
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
const marked = new Marked({
gfm: true,
walkTokens(token) {
if ((token.type === 'link' || token.type === 'image') && !isSafeUrl(token.href)) {
token.href = '#';
}
},
renderer: {
html: ({ text }) => escapeHtml(text),
},
});
export async function renderDescription(markdown: string): Promise<string> {
return marked.parse(markdown);
}
+24
View File
@@ -0,0 +1,24 @@
import { defineLiveCollection } from 'astro:content';
import {
HARBOR_PASSWORD,
HARBOR_PROJECTS,
HARBOR_REGISTRY_HOST,
HARBOR_URL,
HARBOR_USERNAME,
} from 'astro:env/server';
import { harborLoader, repositorySchema } from './loaders/harbor';
// Read at request time from the server environment; nothing here reaches the client.
// Compose passes unset variables as empty strings, hence the `|| undefined`.
const registry = defineLiveCollection({
loader: harborLoader({
url: HARBOR_URL || undefined,
registryHost: HARBOR_REGISTRY_HOST || undefined,
projects: HARBOR_PROJECTS?.split(',').map((p) => p.trim()).filter(Boolean),
username: HARBOR_USERNAME || undefined,
password: HARBOR_PASSWORD || undefined,
}),
schema: repositorySchema,
});
export const collections = { registry };
+298
View File
@@ -0,0 +1,298 @@
import type { LiveLoader } from 'astro/loaders';
import { z } from 'astro/zod';
import { renderDescription } from '../lib/markdown';
/**
* Live (request-time) content loader for a Harbor v2 registry.
*
* Produces one entry per repository (id = "project/repo") with its tagged
* artifacts embedded. The collection listing reuses a repository's artifacts
* from a short-lived in-memory memo while its `update_time` is unchanged; a
* single entry is always fetched fresh (so scan results show up promptly).
*/
export interface HarborLoaderOptions {
/** Base URL of the Harbor instance, e.g. https://registry.example.com */
url?: string;
/** Registry host used in `docker pull` commands. Defaults to the host of `url`. */
registryHost?: string;
/** Limit to these projects. Defaults to all public projects. */
projects?: string[];
/** Optional (robot) account. Anonymous access is enough for public projects. */
username?: string;
password?: string;
/** Skip untagged artifacts (dangling digests). Default: true */
taggedOnly?: boolean;
}
const platformSchema = z.object({
os: z.string(),
architecture: z.string(),
variant: z.string().optional(),
});
const artifactSchema = z.object({
digest: z.string(),
size: z.number(),
pushedAt: z.coerce.date(),
tags: z.array(z.string()),
mediaType: z.string(),
platforms: z.array(platformSchema),
vulnerabilities: z
.object({
severity: z.string(),
total: z.number(),
fixable: z.number(),
bySeverity: z.record(z.string(), z.number()),
})
.optional(),
});
export const repositorySchema = z.object({
project: z.string(),
name: z.string(),
fullName: z.string(),
pullRef: z.string(),
description: z.string(),
pullCount: z.number(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
artifacts: z.array(artifactSchema),
});
export type Repository = z.infer<typeof repositorySchema>;
export type Artifact = z.infer<typeof artifactSchema>;
// --- Harbor API response shapes (only the fields we use) ---
interface HarborProject {
name: string;
}
interface HarborRepository {
name: string; // "project/repo"
description?: string;
pull_count: number;
creation_time: string;
update_time: string;
}
interface HarborArtifact {
digest: string;
size: number;
push_time: string;
media_type: string;
manifest_media_type: string;
tags: { name: string }[] | null;
extra_attrs?: { os?: string; architecture?: string; variant?: string };
references: { platform?: { os: string; architecture: string; variant?: string } }[] | null;
scan_overview?: Record<
string,
{ severity?: string; summary?: { total?: number; fixable?: number; summary?: Record<string, number> } }
>;
}
const PAGE_SIZE = 100;
const CONCURRENCY = 4;
const REQUEST_TIMEOUT_MS = 10_000;
/** Upper bound on how long memoized artifacts are reused (scans finish without bumping `update_time`). */
const MEMO_TTL_MS = 5 * 60 * 1000;
export class HarborError extends Error {
constructor(
message: string,
public status?: number,
options?: ErrorOptions,
) {
super(message, options);
this.name = 'HarborError';
}
}
export function harborLoader(options: HarborLoaderOptions): LiveLoader<Repository, { id: string }, never, HarborError> {
const baseUrl = options.url?.replace(/\/+$/, '');
const registryHost = options.registryHost ?? (baseUrl ? new URL(baseUrl).host : '');
const taggedOnly = options.taggedOnly ?? true;
const api = baseUrl ? createClient(baseUrl, options.username, options.password) : undefined;
const allowed = options.projects && options.projects.length > 0 ? new Set(options.projects) : undefined;
const memo = new Map<string, { updateTime: string; fetchedAt: number; artifacts: Artifact[] }>();
async function fetchArtifacts(repo: HarborRepository): Promise<Artifact[]> {
const [project, ...rest] = repo.name.split('/');
const artifacts = await api!.paginate<HarborArtifact>(
// Harbor requires slashes in repository names to be double-encoded.
`/projects/${enc(project)}/repositories/${enc(enc(rest.join('/')))}/artifacts` +
'?with_tag=true&with_scan_overview=true&with_label=false',
);
const result = artifacts
.map(toArtifact)
.filter((a) => !taggedOnly || a.tags.length > 0)
.sort((a, b) => b.pushedAt.getTime() - a.pushedAt.getTime());
memo.set(repo.name, { updateTime: repo.update_time, fetchedAt: Date.now(), artifacts: result });
return result;
}
function toRepository(repo: HarborRepository, artifacts: Artifact[]): Repository {
const [project, ...rest] = repo.name.split('/');
return {
project,
name: rest.join('/'),
fullName: repo.name,
pullRef: `${registryHost}/${repo.name}`,
description: repo.description ?? '',
pullCount: repo.pull_count,
createdAt: new Date(repo.creation_time),
updatedAt: new Date(repo.update_time),
artifacts,
};
}
return {
name: 'harbor-loader',
loadCollection: async () => {
if (!api) return { entries: [] };
try {
const projectNames = allowed
? [...allowed]
: (await api.paginate<HarborProject>('/projects?public=true')).map((p) => p.name);
const repos = (
await Promise.all(
projectNames.map((project) =>
api.paginate<HarborRepository>(`/projects/${enc(project)}/repositories`),
),
)
).flat();
const entries: { id: string; data: Repository }[] = [];
await mapLimit(repos, CONCURRENCY, async (repo) => {
const cached = memo.get(repo.name);
const artifacts =
cached && cached.updateTime === repo.update_time && Date.now() - cached.fetchedAt < MEMO_TTL_MS
? cached.artifacts
: await fetchArtifacts(repo);
entries.push({ id: repo.name, data: toRepository(repo, artifacts) });
});
// Forget repositories that no longer exist (or are no longer public).
const live = new Set(repos.map((r) => r.name));
for (const name of memo.keys()) if (!live.has(name)) memo.delete(name);
return { entries };
} catch (error) {
return { error: toHarborError(error, 'Failed to load registry') };
}
},
loadEntry: async ({ filter }) => {
const [project, ...rest] = filter.id.split('/');
const name = rest.join('/');
if (!api || !project || !name || (allowed && !allowed.has(project))) return undefined;
try {
// Harbor answers anonymous single-repository GETs with 401, so look it up via the listing.
const matches = await api.get<HarborRepository[] | null>(
`/projects/${enc(project)}/repositories?q=${enc(`name=${filter.id}`)}&page_size=1`,
);
const repo = matches?.find((r) => r.name === filter.id);
if (!repo) return undefined;
// Anonymous access already limits this to public projects; with credentials, check explicitly.
if (!allowed && options.username) {
const meta = await api.get<{ metadata?: { public?: string } }>(`/projects/${enc(project)}`);
if (meta.metadata?.public !== 'true') return undefined;
}
const data = toRepository(repo, await fetchArtifacts(repo));
return {
id: repo.name,
data,
rendered: data.description ? { html: await renderDescription(data.description) } : undefined,
};
} catch (error) {
// Anonymous Harbor answers 401 for private or unknown projects: treat that as "not found" too.
const hidden = [403, 404, ...(options.username ? [] : [401])];
if (error instanceof HarborError && error.status && hidden.includes(error.status)) return undefined;
return { error: toHarborError(error, `Failed to load ${filter.id}`) };
}
},
};
}
function toHarborError(error: unknown, message: string): HarborError {
return error instanceof HarborError ? error : new HarborError(message, undefined, { cause: error });
}
function toArtifact(a: HarborArtifact): Artifact {
const platforms =
a.references && a.references.length > 0
? a.references
.map((r) => r.platform)
.filter((p): p is NonNullable<typeof p> => !!p && p.os !== 'unknown')
: a.extra_attrs?.os && a.extra_attrs.architecture
? [{ os: a.extra_attrs.os, architecture: a.extra_attrs.architecture, variant: a.extra_attrs.variant }]
: [];
const scan = a.scan_overview ? Object.values(a.scan_overview)[0] : undefined;
return {
digest: a.digest,
size: a.size,
pushedAt: new Date(a.push_time),
// `latest` first, then the rest as returned (Harbor's tag order isn't stable).
tags: (a.tags ?? []).map((t) => t.name).sort((x, y) => Number(y === 'latest') - Number(x === 'latest')),
mediaType: a.manifest_media_type || a.media_type,
platforms,
vulnerabilities: scan?.summary
? {
severity: scan.severity ?? 'Unknown',
total: scan.summary.total ?? 0,
fixable: scan.summary.fixable ?? 0,
bySeverity: scan.summary.summary ?? {},
}
: undefined,
};
}
function createClient(baseUrl: string, username?: string, password?: string) {
const headers: Record<string, string> = {
Accept: 'application/json',
'X-Accept-Vulnerabilities':
'application/vnd.security.vulnerability.report; version=1.1, ' +
'application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0',
};
if (username && password) {
headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
}
async function get<T>(path: string): Promise<T> {
const res = await fetch(`${baseUrl}/api/v2.0${path}`, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
if (!res.ok) {
throw new HarborError(`Harbor API ${res.status} ${res.statusText} for ${path}`, res.status);
}
return (await res.json()) as T;
}
async function paginate<T>(path: string): Promise<T[]> {
const sep = path.includes('?') ? '&' : '?';
const all: T[] = [];
for (let page = 1; ; page++) {
const items = await get<T[] | null>(`${path}${sep}page=${page}&page_size=${PAGE_SIZE}`);
if (!items || items.length === 0) break;
all.push(...items);
if (items.length < PAGE_SIZE) break;
}
return all;
}
return { get, paginate };
}
const enc = encodeURIComponent;
async function mapLimit<T>(items: T[], limit: number, fn: (item: T) => Promise<void>) {
const queue = [...items];
await Promise.all(
Array.from({ length: Math.min(limit, queue.length) }, async () => {
for (let item = queue.shift(); item !== undefined; item = queue.shift()) {
await fn(item);
}
}),
);
}
+10
View File
@@ -0,0 +1,10 @@
---
export const prerender = false; // lets on-demand pages rewrite to it
import Base from '../layouts/Base.astro';
---
<Base title="Not found">
<h1>Not found</h1>
<p>That page doesn't exist. <a href="/">Go home</a> or <a href="/registry/">browse the registry</a>.</p>
</Base>
+159
View File
@@ -0,0 +1,159 @@
---
import { Image } from 'astro:assets';
import Base from '../layouts/Base.astro';
import { SITE_TITLE } from '../consts';
import { GITEA_ICON, SOCIAL_LINKS } from '../social';
const NAME = 'Philipp Traber';
// Drop a photo at src/assets/profile.{jpg,jpeg,png,webp,avif} and it is picked up
// automatically; until then an initials placeholder is shown.
const photos = import.meta.glob<{ default: ImageMetadata }>(
'../assets/profile.{jpg,jpeg,png,webp,avif}',
{ eager: true },
);
const photo = Object.values(photos)[0]?.default;
---
<Base title={SITE_TITLE}>
<section class="profile">
{
photo ? (
<Image src={photo} alt={NAME} width={128} height={128} class="avatar" loading="eager" />
) : (
<div class="avatar placeholder" aria-hidden="true">PT</div>
)
}
<h1>{NAME}</h1>
<p class="tagline">
Welcome to my homepage.<br />
Feel free to explore the stuff I do or contact me via LinkedIn.
</p>
<ul class="links">
{
SOCIAL_LINKS.map(({ label, href, icon }) => (
<li>
<a href={href} rel="me noopener" target="_blank">
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<path d={icon} fill="currentColor" />
</svg>
<span>{label}</span>
</a>
</li>
))
}
<li class="own">
<a href="/registry/">
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<path
d="M21 7.5 12 3 3 7.5v9L12 21l9-4.5v-9ZM12 12 3.5 7.75M12 12l8.5-4.25M12 12v8.75"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linejoin="round"
/>
</svg>
<span>Container registry</span>
</a>
</li>
<li class="own">
<a href="https://git.traberph.de/" rel="me noopener" target="_blank">
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<path d={GITEA_ICON} fill="currentColor" />
</svg>
<span>Git server</span>
</a>
</li>
</ul>
</section>
</Base>
<style>
.profile {
max-width: 36rem;
margin-inline: auto;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.75rem;
}
.avatar {
width: 128px;
height: 128px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--border);
}
.placeholder {
display: grid;
place-items: center;
background: var(--surface);
color: var(--muted);
font-size: 2.5rem;
font-weight: 600;
}
h1 {
margin: 0;
font-size: 1.75rem;
line-height: 1.2;
}
.tagline {
margin: 0 0 1rem;
color: var(--muted);
}
.links {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
list-style: none;
margin: 0;
padding: 0;
}
/* Logo pinned left; an equal-width empty column on the right keeps the label centered. */
.links a {
display: grid;
grid-template-columns: 24px 1fr 24px;
align-items: center;
gap: 1rem;
padding: 0.875rem 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
font-weight: 500;
text-decoration: none;
transition: border-color 0.15s, transform 0.15s;
}
.links a:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.links span {
text-align: center;
}
/* Space only above the first self-hosted link, separating it from the social links. */
.links li:not(.own) + .own {
margin-top: 1.5rem;
}
@media (prefers-reduced-motion: reduce) {
.links a {
transition: none;
}
.links a:hover {
transform: none;
}
}
</style>
+135
View File
@@ -0,0 +1,135 @@
---
import { getLiveEntry, render } from 'astro:content';
import { LiveEntryNotFoundError } from 'astro/content/runtime';
import Base from '../../layouts/Base.astro';
import PullCommand from '../../components/PullCommand.astro';
import VulnBadge from '../../components/VulnBadge.astro';
import { formatBytes, formatDate, formatPlatform, shortDigest } from '../../lib/format';
import { REGISTRY_CACHE } from '../../consts';
export const prerender = false;
const { entry: repo, error } = await getLiveEntry('registry', Astro.params.id ?? '');
if (!repo) {
if (error && !(error instanceof LiveEntryNotFoundError)) {
console.error(error);
return new Response('The registry is unreachable right now. Please try again in a moment.', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Retry-After': '30' },
});
}
return Astro.rewrite('/404/');
}
if (Astro.cache.enabled) Astro.cache.set(REGISTRY_CACHE);
const { data } = repo;
const { Content } = await render(repo);
// Prefer the `latest` tag; Harbor doesn't return tags in a stable order.
const latest = data.artifacts.find((a) => a.tags.includes('latest')) ?? data.artifacts[0];
const pullTag = latest?.tags.includes('latest') ? 'latest' : latest?.tags[0];
---
<Base title={data.fullName} description={`Container image ${data.fullName}`}>
<p class="crumbs"><a href="/registry/">Registry</a> / {data.project}</p>
<h1>{data.name}</h1>
{latest && <PullCommand image={pullTag ? `${data.pullRef}:${pullTag}` : `${data.pullRef}@${latest.digest}`} />}
{
data.description && (
<section class="description">
<Content />
</section>
)
}
<h2>Tags</h2>
{
data.artifacts.length === 0 ? (
<p>No tagged artifacts.</p>
) : (
<div class="table-wrap">
<table>
<thead>
<tr>
<th scope="col">Tags</th>
<th scope="col">Digest</th>
<th scope="col">Platforms</th>
<th scope="col">Size</th>
<th scope="col">Pushed</th>
<th scope="col">Scan</th>
</tr>
</thead>
<tbody>
{data.artifacts.map((a) => (
<tr>
<td>
{a.tags.map((t) => (
<code class="tag">{t}</code>
))}
</td>
<td>
<code title={a.digest}>{shortDigest(a.digest)}</code>
</td>
<td>{a.platforms.map(formatPlatform).join(', ') || '—'}</td>
<td>{formatBytes(a.size)}</td>
<td>
<time datetime={a.pushedAt.toISOString()}>{formatDate(a.pushedAt)}</time>
</td>
<td>
<VulnBadge scan={a.vulnerabilities} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
</Base>
<style>
.crumbs {
margin: 0;
color: var(--muted);
}
h1 {
margin-top: 0.25rem;
}
.description {
margin-block: 2rem;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
color: var(--muted);
font-weight: 500;
}
.tag {
display: inline-block;
margin: 0 0.25rem 0.25rem 0;
padding: 0 0.4rem;
background: var(--code-bg);
border-radius: 4px;
}
</style>
+154
View File
@@ -0,0 +1,154 @@
---
import { getLiveCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
import { formatDate } from '../../lib/format';
import { PINNED_IMAGES, REGISTRY_CACHE } from '../../consts';
export const prerender = false;
const { entries = [], error } = await getLiveCollection('registry');
if (error) {
console.error(error);
Astro.response.status = 503;
} else if (Astro.cache.enabled) {
Astro.cache.set(REGISTRY_CACHE);
}
// Pinned images first (in configured order), then the rest by most recently updated.
const pinRank = (id: string) => {
const i = PINNED_IMAGES.indexOf(id);
return i === -1 ? Infinity : i;
};
const repos = entries.toSorted(
(a, b) => pinRank(a.id) - pinRank(b.id) || b.data.updatedAt.getTime() - a.data.updatedAt.getTime(),
);
---
<Base title="Registry" description="Public container images hosted on my Harbor registry.">
<h1>Registry</h1>
{
error ? (
<p>The registry is unreachable right now. Please try again in a moment.</p>
) : repos.length === 0 ? (
<p>No public images yet.</p>
) : (
<>
<label for="filter" class="visually-hidden">
Filter images
</label>
<input id="filter" type="search" placeholder="Filter images…" autocomplete="off" hidden />
<ul class="repos">
{repos.map(({ id, data }) => (
<li
class:list={{ pinned: PINNED_IMAGES.includes(id) }}
data-search={`${data.fullName} ${data.artifacts.flatMap((a) => a.tags).join(' ')}`.toLowerCase()}
>
<a href={`/registry/${id}/`}>
<span class="project">{data.project}/</span>
<strong>{data.name}</strong>
{PINNED_IMAGES.includes(id) && (
<svg class="star" viewBox="0 0 16 16" width="16" height="16" role="img" aria-label="Pinned">
<title>Pinned</title>
<path
fill="currentColor"
d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"
/>
</svg>
)}
</a>
<span class="meta">
{data.artifacts.length} tagged · {data.pullCount} pulls · updated{' '}
<time datetime={data.updatedAt.toISOString()}>{formatDate(data.updatedAt)}</time>
</span>
</li>
))}
</ul>
<p id="no-match" hidden>
No images match.
</p>
</>
)
}
</Base>
<script>
// Client-side filter; the list is fully usable without JS.
const input = document.querySelector<HTMLInputElement>('#filter');
const items = document.querySelectorAll<HTMLLIElement>('.repos li');
const noMatch = document.querySelector<HTMLElement>('#no-match');
if (input && noMatch) {
input.hidden = false;
input.addEventListener('input', () => {
const q = input.value.trim().toLowerCase();
let visible = 0;
for (const li of items) {
li.hidden = !li.dataset.search?.includes(q);
if (!li.hidden) visible++;
}
noMatch.hidden = visible > 0;
});
}
</script>
<style>
input {
width: 100%;
font: inherit;
padding: 0.5rem 0.75rem;
margin-bottom: 1.5rem; /* = gap between pinned and other images (0.5rem grid gap + 1rem) */
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
}
.repos {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.5rem;
}
.repos li {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.25rem 1rem;
padding: 0.75rem 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.repos li[hidden] {
display: none;
}
.repos a {
text-decoration: none;
color: var(--text);
}
.star {
vertical-align: -2px;
margin-left: 0.35rem;
}
/* Separate pinned images from the rest, like the self-hosted links on the homepage. */
.repos .pinned:not([hidden]) + li:not(.pinned) {
margin-top: 1rem;
}
.project,
.meta {
color: var(--muted);
}
.meta {
font-size: 0.875rem;
}
</style>
+27
View File
@@ -0,0 +1,27 @@
// Brand icon paths from Simple Icons (CC0), 24×24 viewBox.
export const SOCIAL_LINKS = [
{
label: 'GitHub',
href: 'https://github.com/traberph/',
icon: 'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12',
},
{
label: 'LinkedIn',
href: 'https://www.linkedin.com/in/traberph/',
icon: 'M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z',
},
{
label: 'Docker Hub',
href: 'https://hub.docker.com/u/traberph',
icon: 'M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z',
},
{
label: 'Hugging Face',
href: 'https://huggingface.co/traberph',
icon: 'M12.025 1.13c-5.77 0-10.449 4.647-10.449 10.378 0 1.112.178 2.181.503 3.185.064-.222.203-.444.416-.577a.96.96 0 0 1 .524-.15c.293 0 .584.124.84.284.278.173.48.408.71.694.226.282.458.611.684.951v-.014c.017-.324.106-.622.264-.874s.403-.487.762-.543c.3-.047.596.06.787.203s.31.313.4.467c.15.257.212.468.233.542.01.026.653 1.552 1.657 2.54.616.605 1.01 1.223 1.082 1.912.055.537-.096 1.059-.38 1.572.637.121 1.294.187 1.967.187.657 0 1.298-.063 1.921-.178-.287-.517-.44-1.041-.384-1.581.07-.69.465-1.307 1.081-1.913 1.004-.987 1.647-2.513 1.657-2.539.021-.074.083-.285.233-.542.09-.154.208-.323.4-.467a1.08 1.08 0 0 1 .787-.203c.359.056.604.29.762.543s.247.55.265.874v.015c.225-.34.457-.67.683-.952.23-.286.432-.52.71-.694.257-.16.547-.284.84-.285a.97.97 0 0 1 .524.151c.228.143.373.388.43.625l.006.04a10.3 10.3 0 0 0 .534-3.273c0-5.731-4.678-10.378-10.449-10.378M8.327 6.583a1.5 1.5 0 0 1 .713.174 1.487 1.487 0 0 1 .617 2.013c-.183.343-.762-.214-1.102-.094-.38.134-.532.914-.917.71a1.487 1.487 0 0 1 .69-2.803m7.486 0a1.487 1.487 0 0 1 .689 2.803c-.385.204-.536-.576-.916-.71-.34-.12-.92.437-1.103.094a1.487 1.487 0 0 1 .617-2.013 1.5 1.5 0 0 1 .713-.174m-10.68 1.55a.96.96 0 1 1 0 1.921.96.96 0 0 1 0-1.92m13.838 0a.96.96 0 1 1 0 1.92.96.96 0 0 1 0-1.92M8.489 11.458c.588.01 1.965 1.157 3.572 1.164 1.607-.007 2.984-1.155 3.572-1.164.196-.003.305.12.305.454 0 .886-.424 2.328-1.563 3.202-.22-.756-1.396-1.366-1.63-1.32q-.011.001-.02.006l-.044.026-.01.008-.03.024q-.018.017-.035.036l-.032.04a1 1 0 0 0-.058.09l-.014.025q-.049.088-.11.19a1 1 0 0 1-.083.116 1.2 1.2 0 0 1-.173.18q-.035.029-.075.058a1.3 1.3 0 0 1-.251-.243 1 1 0 0 1-.076-.107c-.124-.193-.177-.363-.337-.444-.034-.016-.104-.008-.2.022q-.094.03-.216.087-.06.028-.125.063l-.13.074q-.067.04-.136.086a3 3 0 0 0-.135.096 3 3 0 0 0-.26.219 2 2 0 0 0-.12.121 2 2 0 0 0-.106.128l-.002.002a2 2 0 0 0-.09.132l-.001.001a1.2 1.2 0 0 0-.105.212q-.013.036-.024.073c-1.139-.875-1.563-2.317-1.563-3.203 0-.334.109-.457.305-.454m.836 10.354c.824-1.19.766-2.082-.365-3.194-1.13-1.112-1.789-2.738-1.789-2.738s-.246-.945-.806-.858-.97 1.499.202 2.362c1.173.864-.233 1.45-.685.64-.45-.812-1.683-2.896-2.322-3.295s-1.089-.175-.938.647 2.822 2.813 2.562 3.244-1.176-.506-1.176-.506-2.866-2.567-3.49-1.898.473 1.23 2.037 2.16c1.564.932 1.686 1.178 1.464 1.53s-3.675-2.511-4-1.297c-.323 1.214 3.524 1.567 3.287 2.405-.238.839-2.71-1.587-3.216-.642-.506.946 3.49 2.056 3.522 2.064 1.29.33 4.568 1.028 5.713-.624m5.349 0c-.824-1.19-.766-2.082.365-3.194 1.13-1.112 1.789-2.738 1.789-2.738s.246-.945.806-.858.97 1.499-.202 2.362c-1.173.864.233 1.45.685.64.451-.812 1.683-2.896 2.322-3.295s1.089-.175.938.647-2.822 2.813-2.562 3.244 1.176-.506 1.176-.506 2.866-2.567 3.49-1.898-.473 1.23-2.037 2.16c-1.564.932-1.686 1.178-1.464 1.53s3.675-2.511 4-1.297c.323 1.214-3.524 1.567-3.287 2.405.238.839 2.71-1.587 3.216-.642.506.946-3.49 2.056-3.522 2.064-1.29.33-4.568 1.028-5.713-.624',
},
] as const;
// Self-hosted services, listed below the social links.
export const GITEA_ICON =
'M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z';
+84
View File
@@ -0,0 +1,84 @@
:root {
color-scheme: light dark;
--bg: #fafaf9;
--surface: #ffffff;
--text: #1c1917;
--muted: #57534e;
--border: #e7e5e4;
--accent: #0e7490;
--code-bg: #f5f5f4;
--sev-critical: #b91c1c;
--sev-high: #c2410c;
--sev-medium: #a16207;
--sev-low: #4d7c0f;
--sev-none: #15803d;
--font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
--radius: 8px;
--gap: 1rem;
--max-width: 64rem;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0c0a09;
--surface: #1c1917;
--text: #f5f5f4;
--muted: #a8a29e;
--border: #292524;
--accent: #22d3ee;
--code-bg: #292524;
--sev-critical: #f87171;
--sev-high: #fb923c;
--sev-medium: #facc15;
--sev-low: #a3e635;
--sev-none: #4ade80;
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
line-height: 1.6;
}
a {
color: var(--accent);
}
code,
pre {
font-family: var(--font-mono);
font-size: 0.875em;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}