Skip to content

Commit 919f853

Browse files
askovrogaldh
andauthored
feat: add OG image generation for feature gate pages (#913)
## Description - Add Open Graph image generation for feature gate pages so shared links display a rich preview with the feature title and SIMD number(s). Works for `/address/<address>/feature-gate` [example](https://explorer.solana.com/address/5xXZc66h4UdB6Yq7FzdBxBiRAFMMScMLwHxk2QZDaNZL/feature-gate?cluster=testnet) and `/address/<address>` [example](https://explorer.solana.com/address/5xXZc66h4UdB6Yq7FzdBxBiRAFMMScMLwHxk2QZDaNZL?cluster=testnet) - Refactor `app/features/feature-gate/` into FSD-aligned sub-modules (`api/`, `lib/`, `ui/`, `env.ts`, `server.ts`) and narrow the client-facing `index.ts` export - Extract `SolanaLogo` from receipt feature into `app/shared/components/` with `variant` (`gradient` | `green`) and unique SVG `id` props to support reuse across OG images ## Type of change <!-- Check the appropriate options that apply to this PR --> - [x] New feature ## Screenshots <img width="2184" height="755" alt="localhost_3000_og_feature-gate_5xXZc66h4UdB6Yq7FzdBxBiRAFMMScMLwHxk2QZDaNZL" src="https://github.com/user-attachments/assets/1739f84e-76ef-41c3-9a8f-aefdbd06f49e" /> ## Testing ### Environment - FEATURE_GATE_OG_ENABLED — set to "true" to enable OG images on feature gate pages (default: disabled) - FEATURE_GATE_BASE_URL — set to https://explorer.solana.com ### Vercel Firewall Configuration If Attack Challenge Mode is enabled, add bypass rules so social media crawlers can fetch preview images and visit feature gate pages: Name: Allow bots for feature gate OG images Rule: If `Request Path` `Starts with` `/og/feature-gate/` Then `Bypass` Name: Allow bots to visit feature gate address pages Rule: If `Request Path` `Starts with` `/address/` Then `Bypass` ## Test plan - [ ] Set `FEATURE_GATE_OG_ENABLED=true` and visit `/og/feature-gate/<known-feature-address>` — should return a PNG - [ ] Visit `/og/feature-gate/<unknown-address>` — should return 404 - [ ] Visit `/og/feature-gate/invalid!!!` — should return 400 - [ ] With `FEATURE_GATE_OG_ENABLED=false`, all OG routes return 404 and pages have no `og:image` meta - [ ] Existing receipt OG images still work (Logo import path changed) ## Related Issues Closes [HOO-384](https://linear.app/solana-fndn/issue/HOO-384/add-share-preview-images-for-explorer-features) ## Checklist <!-- Verify that you have completed the following before requesting review --> - [x] My code follows the project's style guidelines - [x] I have added tests that prove my fix/feature works - [x] All tests pass locally and in CI - [x] I have updated documentation as needed - [x] I have run `build:info` script to update build information - [x] CI/CD checks pass - [x] I have included screenshots for protocol screens (if applicable) --------- Co-authored-by: Sergo <rogaldh@radsh.red>
1 parent 6fc2569 commit 919f853

File tree

20 files changed

+577
-98
lines changed

20 files changed

+577
-98
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ RECEIPT_BASE_URL=https://explorer.solana.com
4545
RECEIPT_OG_IMAGE_VERSION=1
4646
### Comma-separated Jito tip recipient addresses (optional; defaults to known Jito accounts). Exposed to client.
4747
NEXT_PUBLIC_RECEIPT_JITO_ACCOUNTS=3AVi9Tg9Uo68tJfuvoKvqKNWKkC5wPdSSdeBnizKZ6jT,HFqU5x63VTqvQss8hp11i4wVV8bD44PvwucfZ2bU7gRe
48+
## Configuration for "Feature Gate OG" feature
49+
### Set to "true" to enable OG images on feature gate pages
50+
FEATURE_GATE_OG_ENABLED=false
51+
### Base URL for OG image links (defaults to https://explorer.solana.com)
52+
FEATURE_GATE_BASE_URL=https://explorer.solana.com
4853
## Configuration for token verification feature
4954
### Configuration for Jupiter API
5055
JUPITER_API_KEY=

app/address/[address]/feature-gate/page.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import ReactMarkdown from 'react-markdown';
55
import remarkFrontmatter from 'remark-frontmatter';
66
import remarkGFM from 'remark-gfm';
77

8-
import { fetchFeatureGateInformation } from '@/app/features/feature-gate';
8+
import { fetchFeatureGateInformation, getFeatureGateOpenGraph } from '@/app/features/feature-gate/server';
99
import { getFeatureInfo } from '@/app/utils/feature-gate/utils';
1010

1111
type Props = Readonly<{
@@ -15,9 +15,11 @@ type Props = Readonly<{
1515
}>;
1616

1717
export async function generateMetadata(props: AddressPageMetadataProps): Promise<Metadata> {
18+
const title = `Feature Gate | ${await getReadableTitleFromAddress(props)} | Solana`;
1819
return {
1920
description: `Feature information for address ${props.params.address} on Solana`,
20-
title: `Feature Gate | ${await getReadableTitleFromAddress(props)} | Solana`,
21+
openGraph: getFeatureGateOpenGraph(props.params.address),
22+
title,
2123
};
2224
}
2325

app/address/[address]/page.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { TransactionHistoryCard } from '@components/account/history/TransactionH
22
import getReadableTitleFromAddress, { AddressPageMetadataProps } from '@utils/get-readable-title-from-address';
33
import { Metadata } from 'next/types';
44

5+
import { getFeatureGateOpenGraph } from '@/app/features/feature-gate/server';
56
import { TransactionsProvider } from '@/app/providers/transactions';
67

78
type Props = Readonly<{
@@ -11,9 +12,14 @@ type Props = Readonly<{
1112
}>;
1213

1314
export async function generateMetadata(props: AddressPageMetadataProps): Promise<Metadata> {
15+
const title = `Transaction History | ${await getReadableTitleFromAddress(props)} | Solana`;
1416
return {
1517
description: `History of all transactions involving the address ${props.params.address} on Solana`,
16-
title: `Transaction History | ${await getReadableTitleFromAddress(props)} | Solana`,
18+
// Feature gate OG images are intentionally shown on the main address page too,
19+
// so shared links to feature gate addresses always display the rich preview.
20+
// e.g. /address/5xXZc66h4UdB6Yq7FzdBxBiRAFMMScMLwHxk2QZDaNZL?cluster=testnet
21+
openGraph: getFeatureGateOpenGraph(props.params.address),
22+
title,
1723
};
1824
}
1925

app/components/shared/__stories__/HexData.stories.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,8 @@ const truncateShortData = new Uint8Array([0xfe, 0xab, 0xcd]);
2121
const truncateLongData = new Uint8Array(Array.from({ length: 67 }, (_, i) => i));
2222
const atThresholdData = new Uint8Array(Array.from({ length: 16 }, (_, i) => i + 0xa0));
2323

24-
// Centered default for autodocs preview
2524
export const Default: Story = {
2625
args: { align: 'start', raw: multiRowData },
27-
parameters: { layout: 'centered' },
2826
};
2927

3028
// ── Empty ────────────────────────────────────────────────────────────

app/features/feature-gate/__tests__/feature-gate.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { vi } from 'vitest';
33

44
import { FeatureInfoType } from '@/app/utils/feature-gate/types';
55

6-
import { fetchFeatureGateInformation, getLink } from '../index';
6+
import { fetchFeatureGateInformation, getLink } from '../api/fetchFeatureGateInformation';
77

88
// Taken from ../../../utils/feature-gate/featureGates.json
99
const FEATURE: FeatureInfoType = {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import fetch from 'cross-fetch';
2+
3+
import { Logger } from '@/app/shared/lib/logger';
4+
import { FeatureInfoType } from '@/app/utils/feature-gate/types';
5+
6+
// Good candidate to move to environment variables, but at the moment repository is public, so we leave them hardcoded (could be changed later)
7+
const OWNER = 'solana-foundation';
8+
const REPO = 'solana-improvement-documents';
9+
const BRANCH = 'main';
10+
const PATH_COMPONENT = 'proposals';
11+
12+
export async function fetchFeatureGateInformation(featureInfo?: FeatureInfoType): Promise<string[]> {
13+
const empty: string[] = ['No data'];
14+
15+
const fileNames = featureInfo?.simd_link;
16+
17+
if (!fileNames) return empty;
18+
19+
const results = await Promise.all(
20+
fileNames.map(async fileName => {
21+
const link = getLink(fileName);
22+
try {
23+
const resp = await fetch(link, { method: 'GET' });
24+
25+
if (!resp.ok) {
26+
Logger.warn('[feature-gate] SIMD fetch failed', { link, status: resp.status });
27+
return 'No data';
28+
}
29+
30+
return resp.text();
31+
} catch (e) {
32+
Logger.warn('[feature-gate] Cannot fetch link', { cause: e, link });
33+
return 'No data';
34+
}
35+
}),
36+
);
37+
38+
return results;
39+
}
40+
41+
export function getLink(simdLink: string) {
42+
// All the READMEs are stored at the same directory. That's why we only need the file name.
43+
const components = simdLink.split('/');
44+
const file = components[components.length - 1];
45+
46+
const uri = `https://raw.githubusercontent.com/${OWNER}/${REPO}/${BRANCH}/${PATH_COMPONENT}/${file}`;
47+
48+
return uri;
49+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const IMAGE_SIZE = {
2+
height: 630,
3+
width: 1200,
4+
};

app/features/feature-gate/env.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { EXPLORER_BASE_URL, isEnvEnabled } from '@utils/env';
2+
3+
export function isFeatureGateOgEnabled() {
4+
return isEnvEnabled(process.env.FEATURE_GATE_OG_ENABLED);
5+
}
6+
7+
// Empty string is valid (relative URLs), only undefined falls back to EXPLORER_BASE_URL
8+
export const FEATURE_GATE_BASE_URL =
9+
process.env.FEATURE_GATE_BASE_URL !== undefined ? process.env.FEATURE_GATE_BASE_URL.trim() : EXPLORER_BASE_URL;

app/features/feature-gate/index.ts

Lines changed: 1 addition & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1 @@
1-
import fetch from 'cross-fetch';
2-
3-
import { Logger } from '@/app/shared/lib/logger';
4-
import { Cluster } from '@/app/utils/cluster';
5-
import { FeatureInfoType } from '@/app/utils/feature-gate/types';
6-
7-
// Good candidate to move to environment variables, but at the moment repository is public, so we leave them hardcoded (could be changed later)
8-
const OWNER = 'solana-foundation';
9-
const REPO = 'solana-improvement-documents';
10-
const BRANCH = 'main';
11-
const PATH_COMPONENT = 'proposals';
12-
13-
export function getLink(simdLink: string) {
14-
// All the READMEs are stored at the same directory. That's why we only need the file name.
15-
const components = simdLink.split('/');
16-
const file = components[components.length - 1];
17-
18-
const uri = `https://raw.githubusercontent.com/${OWNER}/${REPO}/${BRANCH}/${PATH_COMPONENT}/${file}`;
19-
20-
return uri;
21-
}
22-
23-
export async function fetchFeatureGateInformation(featureInfo?: FeatureInfoType): Promise<string[]> {
24-
const empty: string[] = ['No data'];
25-
26-
const fileNames = featureInfo?.simd_link ?? null;
27-
28-
if (fileNames === null) return empty;
29-
30-
const results = await Promise.all(
31-
fileNames.map(async fileName => {
32-
const link = getLink(fileName);
33-
try {
34-
const resp = await fetch(link, { method: 'GET' });
35-
36-
if (!resp.ok) return 'No data';
37-
38-
return resp.text();
39-
} catch (_e) {
40-
Logger.debug('[feature-gate] Cannot fetch link', { link });
41-
return 'No data';
42-
}
43-
}),
44-
);
45-
46-
return results;
47-
}
48-
49-
export function isFeatureActivated(feature: FeatureInfoType, cluster: Cluster) {
50-
switch (cluster) {
51-
case Cluster.MainnetBeta:
52-
return feature.mainnet_activation_epoch !== null;
53-
case Cluster.Devnet:
54-
return feature.devnet_activation_epoch !== null;
55-
case Cluster.Testnet:
56-
return feature.testnet_activation_epoch !== null;
57-
default:
58-
return false;
59-
}
60-
}
1+
export { isFeatureActivated } from './lib/isFeatureActivated';
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { Metadata } from 'next/types';
2+
3+
import { getFeatureInfo } from '@/app/utils/feature-gate/utils';
4+
5+
import { IMAGE_SIZE } from '../constants';
6+
import { FEATURE_GATE_BASE_URL, isFeatureGateOgEnabled } from '../env';
7+
8+
export function getFeatureGateOpenGraph(address: string): Metadata['openGraph'] | undefined {
9+
if (!isFeatureGateOgEnabled()) return undefined;
10+
11+
const featureInfo = getFeatureInfo(address);
12+
if (!featureInfo) return undefined;
13+
14+
return {
15+
images: [{ ...IMAGE_SIZE, url: `${FEATURE_GATE_BASE_URL}/og/feature-gate/${address}` }],
16+
title: `Feature Gate | ${featureInfo.title} | Solana`,
17+
};
18+
}

0 commit comments

Comments
 (0)