- 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:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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/
|
||||||
@@ -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.
|
||||||
+24
@@ -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"]
|
||||||
@@ -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`.
|
||||||
@@ -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 }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Generated
+3489
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
sharp: true
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -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
@@ -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 |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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'];
|
||||||
@@ -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>
|
||||||
@@ -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('/');
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user