mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
Add open-graph card generation via Cloudflare Workers
Deploys two workers: og-injector intercepts social crawler requests and injects Open Graph meta tags; open-graph renders 1200×630 PNG cards using Satori with repo/user avatars ported from avatar.ts.
This commit is contained in:
@@ -25,3 +25,6 @@ tests/visual/snapshots/**/*
|
||||
# IDEs and Editors
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Wrangler local dev state
|
||||
.wrangler/
|
||||
|
||||
@@ -111,6 +111,7 @@ export default [
|
||||
reportUnusedDisableDirectives: "error",
|
||||
},
|
||||
files: ["**/*.js", "**/*.ts", "**/*.svelte"],
|
||||
ignores: ["workers/**/*"],
|
||||
})),
|
||||
...svelte.configs["flat/recommended"],
|
||||
...svelte.configs["flat/prettier"],
|
||||
@@ -133,12 +134,38 @@ export default [
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
},
|
||||
},
|
||||
// Workers: basic JS linting without TypeScript type-checking.
|
||||
{
|
||||
files: ["workers/**/*.js"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
"prefer-arrow-callback": "warn",
|
||||
"prefer-const": "warn",
|
||||
"no-const-assign": "error",
|
||||
"no-var": "warn",
|
||||
eqeqeq: "warn",
|
||||
"no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
"no-shadow": "warn",
|
||||
"no-throw-literal": "error",
|
||||
"no-implicit-coercion": "warn",
|
||||
"no-param-reassign": "warn",
|
||||
"no-return-assign": "error",
|
||||
"no-sequences": "error",
|
||||
"no-template-curly-in-string": "warn",
|
||||
"prefer-template": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**/*",
|
||||
"build/**/*",
|
||||
"public/**/*",
|
||||
"radicle-httpd/**/*",
|
||||
"workers/**/node_modules/**/*",
|
||||
"workers/**/.wrangler/**/*",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
-17
@@ -8,23 +8,6 @@
|
||||
<meta name="name" content="Radicle Explorer" />
|
||||
<meta name="description" content="Explore the Radicle network" />
|
||||
|
||||
<meta property="og:url" content="https://app.radicle.xyz" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Radicle Explorer" />
|
||||
<meta property="og:description" content="Explore the Radicle network" />
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://app.radicle.xyz/images/radicle-og-853x853-0569.png" />
|
||||
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta property="twitter:domain" content="app.radicle.xyz" />
|
||||
<meta property="twitter:url" content="https://app.radicle.xyz" />
|
||||
<meta name="twitter:title" content="Radicle Explorer" />
|
||||
<meta name="twitter:description" content="Explore the Radicle network" />
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://app.radicle.xyz/images/radicle-og-853x853-0569.png" />
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/Booton-Regular.woff2"
|
||||
|
||||
+3
-1
@@ -15,7 +15,9 @@
|
||||
"test:e2e:local": "scripts/compile-local-httpd && USE_LOCAL_HTTPD=true NODE_CONFIG_ENV='test' TZ='UTC' playwright test",
|
||||
"test:http-client:unit": "NODE_CONFIG_ENV='test' TZ='UTC' vitest run --config http-client/vite.config.ts --reporter verbose",
|
||||
"test:radicle-httpd": "cd radicle-httpd && cargo test --all-features",
|
||||
"deploy": "rimraf build && npm clean-install && npm run build && scripts/inject-plausible && npx wrangler deploy"
|
||||
"deploy": "rimraf build && npm clean-install && npm run build && scripts/inject-plausible && npx wrangler deploy",
|
||||
"deploy:open-graph": "cd workers/open-graph && npx wrangler deploy",
|
||||
"start:open-graph": "cd workers/open-graph && npx wrangler dev"
|
||||
},
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,84 @@
|
||||
# OG Image Generation
|
||||
|
||||
Two components work together to serve Open Graph preview images for
|
||||
social media embeds (Twitter/X, Slack, Discord, etc.).
|
||||
|
||||
## Components
|
||||
|
||||
**og-injector** (`og-injector.js`) runs as middleware on the app itself
|
||||
(Cloudflare Workers). It injects `<meta og:image>`, `<meta og:title>`,
|
||||
and related Open Graph tags into every HTML response, pointing to the
|
||||
OG image worker.
|
||||
|
||||
**open-graph** (`open-graph/index.js`) is a standalone Cloudflare
|
||||
Worker that generates PNG cards on the fly using Satori (HTML/CSS to
|
||||
SVG) and resvg-wasm (SVG to PNG). It fetches data from the radicle-httpd
|
||||
API, generates procedural avatars, and caches results at the edge.
|
||||
|
||||
Request flow: request hits app -> og-injector injects og:image URL ->
|
||||
social crawler fetches image from open-graph worker -> worker calls
|
||||
httpd API, renders PNG, caches and returns it.
|
||||
|
||||
## Routes
|
||||
|
||||
| URL pattern | Card type |
|
||||
|-------------|-----------|
|
||||
| `/` | Home (forest image + wordmark) |
|
||||
| `/nodes/:host` | Node (avatar + hostname + stats) |
|
||||
| `/nodes/:host/users/:did` | User (avatar + alias + DID) |
|
||||
| `/nodes/:host/:rid` | Repository (avatar + name + stats + delegates) |
|
||||
| `/nodes/:host/:rid/issues` | Issues listing (open + closed counts) |
|
||||
| `/nodes/:host/:rid/patches` | Patches listing (open/draft/archived/merged counts) |
|
||||
| `/nodes/:host/:rid/history` | Commit history (branch + commit count) |
|
||||
| `/nodes/:host/:rid/remotes/:peer/history/:branch?` | Commit history (peer branch) |
|
||||
| `/nodes/:host/:rid/issues/:id` | Single issue (state pill + title + author) |
|
||||
| `/nodes/:host/:rid/patches/:id` | Single patch (state pill + title + author) |
|
||||
| `/nodes/:host/:rid/commits/:sha` | Single commit (title + author/committer gravatars) |
|
||||
|
||||
## Fallback chain
|
||||
|
||||
When data fetching fails, cards degrade gracefully:
|
||||
|
||||
single issue/patch/commit -> repo card -> node card
|
||||
issues/patches/history listing -> repo card -> node card
|
||||
repo card -> node card
|
||||
node/user/home -> always renders
|
||||
|
||||
## Fonts
|
||||
|
||||
Two weights of Booton are bundled as worker data assets:
|
||||
- `Booton-Regular.ttf` (400) for body text
|
||||
- `Booton-SemiBold.ttf` (600) for headings and labels
|
||||
|
||||
## Caching
|
||||
|
||||
Two layers of caching:
|
||||
|
||||
- **open-graph worker**: Uses Cloudflare's Cache API (`caches.default`)
|
||||
at the edge. Responses carry `Cache-Control: public, max-age=120`
|
||||
(2 minutes).
|
||||
- **og-injector**: Injected HTML responses carry
|
||||
`Cache-Control: public, max-age=300` (5 minutes).
|
||||
|
||||
## Deployment
|
||||
|
||||
### og-injector
|
||||
|
||||
Deployed as part of the Cloudflare Pages project via `npm run deploy`.
|
||||
The `OG_IMAGE_BASE` env var is set to the open-graph worker origin
|
||||
(`https://open-graph.radicle.network`).
|
||||
|
||||
### open-graph worker
|
||||
|
||||
```sh
|
||||
npm run deploy:open-graph
|
||||
```
|
||||
|
||||
For local development:
|
||||
|
||||
```sh
|
||||
npm run start:open-graph
|
||||
```
|
||||
|
||||
The worker bundles fonts (`.ttf`), the forest image (`.jpg`), and the
|
||||
resvg WASM module as data assets configured in `wrangler.toml`.
|
||||
@@ -0,0 +1,166 @@
|
||||
function parseRoute(pathname) {
|
||||
const segments = pathname.replace(/^\//, "").split("/");
|
||||
const first = segments[0];
|
||||
|
||||
// Home
|
||||
if (first === "" || first === undefined) {
|
||||
return { type: "home" };
|
||||
}
|
||||
|
||||
if (first !== "nodes" && first !== "seeds") return null;
|
||||
|
||||
const host = segments[1];
|
||||
if (!host) return { type: "home" };
|
||||
|
||||
const rid = segments[2];
|
||||
|
||||
// /nodes/:host
|
||||
if (!rid) return { type: "node", host };
|
||||
|
||||
// /nodes/:host/users/:did
|
||||
if (rid === "users") {
|
||||
const did = segments[3];
|
||||
return did ? { type: "user", host, did } : { type: "node", host };
|
||||
}
|
||||
|
||||
// /nodes/:host/:rid/issues/:id
|
||||
if (segments[3] === "issues" && segments[4]) {
|
||||
return { type: "issue", host, rid, id: segments[4] };
|
||||
}
|
||||
// /nodes/:host/:rid/issues
|
||||
if (segments[3] === "issues") {
|
||||
return { type: "issues", host, rid };
|
||||
}
|
||||
// /nodes/:host/:rid/patches/:id
|
||||
if (segments[3] === "patches" && segments[4]) {
|
||||
return { type: "patch", host, rid, id: segments[4] };
|
||||
}
|
||||
// /nodes/:host/:rid/patches
|
||||
if (segments[3] === "patches") {
|
||||
return { type: "patches", host, rid };
|
||||
}
|
||||
// /nodes/:host/:rid/commits/:sha
|
||||
if (segments[3] === "commits" && segments[4]) {
|
||||
return { type: "commit", host, rid, sha: segments[4] };
|
||||
}
|
||||
|
||||
// /nodes/:host/:rid/history/:branch
|
||||
if (segments[3] === "history") {
|
||||
return { type: "history", host, rid };
|
||||
}
|
||||
|
||||
// /nodes/:host/:rid/remotes/:peer/history/:branch
|
||||
if (segments[3] === "remotes" && segments[4] && segments[5] === "history") {
|
||||
return { type: "history", host, rid };
|
||||
}
|
||||
|
||||
// /nodes/:host/:rid — source, tree, etc. share the repo card
|
||||
return { type: "repo", host, rid };
|
||||
}
|
||||
|
||||
function shortenDid(did) {
|
||||
const match = /^did:key:(z[a-zA-Z0-9]+)$/.exec(did);
|
||||
if (!match) return did;
|
||||
const pubkey = match[1];
|
||||
return `${pubkey.substring(0, 8)}…${pubkey.slice(-8)}`;
|
||||
}
|
||||
|
||||
function titleForRoute(route) {
|
||||
switch (route.type) {
|
||||
case "home":
|
||||
return "Radicle Explorer · Decentralized Code Collaboration";
|
||||
case "node":
|
||||
return `Radicle Seed Node · ${route.host}`;
|
||||
case "user":
|
||||
return `Radicle User · ${shortenDid(route.did)} · ${route.host}`;
|
||||
case "repo":
|
||||
return `Radicle Repo · ${route.rid} · ${route.host}`;
|
||||
case "issues":
|
||||
return `Issues · ${route.rid} · ${route.host}`;
|
||||
case "patches":
|
||||
return `Patches · ${route.rid} · ${route.host}`;
|
||||
case "issue":
|
||||
return `Issue ${route.id.slice(0, 7)} · ${route.rid} · ${route.host}`;
|
||||
case "patch":
|
||||
return `Patch ${route.id.slice(0, 7)} · ${route.rid} · ${route.host}`;
|
||||
case "commit":
|
||||
return `Commit ${route.sha.slice(0, 7)} · ${route.rid} · ${route.host}`;
|
||||
case "history":
|
||||
return `Commit History · ${route.rid} · ${route.host}`;
|
||||
default:
|
||||
return "Radicle Explorer · Decentralized Code Collaboration";
|
||||
}
|
||||
}
|
||||
|
||||
function descriptionForRoute(route) {
|
||||
const h = route.host;
|
||||
switch (route.type) {
|
||||
case "home":
|
||||
return "Explore open-source repositories, issues, and patches on the Radicle peer-to-peer code collaboration network. Sovereign hosting without central servers.";
|
||||
case "node":
|
||||
return `Browse repositories hosted on the ${h} Radicle seed node. Explore source code, issues, patches, and contributor activity.`;
|
||||
case "user":
|
||||
return `Radicle user profile on ${h}. Browse repositories, patches, and contributions across the peer-to-peer network.`;
|
||||
case "repo":
|
||||
return `Radicle repository hosted on ${h}. Browse source code, issues, patches, commit history, and contributor activity.`;
|
||||
case "issues":
|
||||
return `Browse issues for this repository on ${h}. View bug reports, feature requests, and ongoing discussions on the Radicle network.`;
|
||||
case "patches":
|
||||
return `Browse patches for this repository on ${h}. View proposed changes, code reviews, and merge status on the Radicle network.`;
|
||||
case "issue":
|
||||
return `View this issue and its discussion in a Radicle repository on ${h}. Track progress, comments, and resolution.`;
|
||||
case "patch":
|
||||
return `View this patch and its review in a Radicle repository on ${h}. Browse revisions, review comments, and merge status.`;
|
||||
case "commit":
|
||||
return `View this commit and its diff in a Radicle repository on ${h}. Inspect changed files, author details, and additions.`;
|
||||
case "history":
|
||||
return `Browse the commit history of this Radicle repository on ${h}. View commits, contributors, and development activity.`;
|
||||
default:
|
||||
return "Explore open-source repositories, issues, and patches on the Radicle peer-to-peer code collaboration network.";
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const url = new URL(request.url);
|
||||
const route = parseRoute(url.pathname);
|
||||
|
||||
if (!route) {
|
||||
return env.ASSETS.fetch(request);
|
||||
}
|
||||
|
||||
const hostParam = route.type === "home" ? `?host=${url.hostname}` : "";
|
||||
const ogImageUrl = `${env.OG_IMAGE_BASE}${url.pathname}${hostParam}`;
|
||||
const title = titleForRoute(route);
|
||||
const description = descriptionForRoute(route);
|
||||
|
||||
const indexResponse = await env.ASSETS.fetch(
|
||||
new Request(new URL("/", url)),
|
||||
);
|
||||
const html = await indexResponse.text();
|
||||
|
||||
const tags = [
|
||||
`<meta property="og:title" content="${title}" />`,
|
||||
`<meta property="og:description" content="${description}" />`,
|
||||
`<meta property="og:image" content="${ogImageUrl}" />`,
|
||||
`<meta property="og:image:width" content="1200" />`,
|
||||
`<meta property="og:image:height" content="630" />`,
|
||||
`<meta property="og:url" content="${url.href}" />`,
|
||||
`<meta property="og:type" content="website" />`,
|
||||
`<meta property="og:site_name" content="Radicle Explorer" />`,
|
||||
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||
`<meta name="twitter:site" content="@radicle" />`,
|
||||
`<meta name="theme-color" content="#1c77ff" />`,
|
||||
].join("\n ");
|
||||
|
||||
const injected = html.replace("</head>", ` ${tags}\n</head>`);
|
||||
|
||||
return new Response(injected, {
|
||||
status: indexResponse.status,
|
||||
headers: {
|
||||
"content-type": "text/html;charset=UTF-8",
|
||||
"cache-control": "public, max-age=300",
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 322 KiB |
File diff suppressed because it is too large
Load Diff
Generated
+1698
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "open-graph",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@resvg/resvg-wasm": "^2.6.0",
|
||||
"satori": "^0.10.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
name = "open-graph"
|
||||
compatibility_date = "2025-04-03"
|
||||
main = "index.js"
|
||||
|
||||
routes = [
|
||||
{ pattern = "open-graph.radicle.network/*", zone_name = "radicle.network" },
|
||||
]
|
||||
|
||||
[[rules]]
|
||||
type = "CompiledWasm"
|
||||
globs = ["**/*.wasm"]
|
||||
fallthrough = true
|
||||
|
||||
[[rules]]
|
||||
type = "Data"
|
||||
globs = ["**/*.ttf", "**/*.jpg"]
|
||||
fallthrough = true
|
||||
@@ -1,7 +1,13 @@
|
||||
name = "explorer"
|
||||
compatibility_date = "2025-04-03"
|
||||
account_id = "dcd58b4607e42dafa1592d13077a60bc"
|
||||
main = "workers/og-injector.js"
|
||||
|
||||
[assets]
|
||||
directory = "build"
|
||||
binding = "ASSETS"
|
||||
not_found_handling = "single-page-application"
|
||||
run_worker_first = true
|
||||
|
||||
[vars]
|
||||
OG_IMAGE_BASE = "https://open-graph.radicle.network"
|
||||
|
||||
Reference in New Issue
Block a user