From 832ad090701036ebb295b0f20ce50356ef8ca1f7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 14:31:22 +0000 Subject: [PATCH] fix: add favicon and social preview image Use the NETH mark as the site favicon and hero.png for Open Graph and Twitter large-image cards so crawlers and X previews show Nether. Co-authored-by: Rastsislau Lipski --- apps/web/src/layouts/Layout.astro | 22 ++++++++++++++ apps/web/src/lib/nethMark.ts | 14 ++------- apps/web/src/lib/site.test.ts | 48 +++++++++++++++++++++++++++++++ apps/web/src/lib/site.ts | 30 ++++++++++++++++--- apps/web/src/pages/index.astro | 9 ++++-- 5 files changed, 104 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/lib/site.test.ts diff --git a/apps/web/src/layouts/Layout.astro b/apps/web/src/layouts/Layout.astro index 8bd1718..da67b0b 100644 --- a/apps/web/src/layouts/Layout.astro +++ b/apps/web/src/layouts/Layout.astro @@ -2,6 +2,14 @@ import { Font } from 'astro:assets'; import '../styles/global.css'; import Header from '../components/Header.astro'; +import { NETH_MARK_FILE } from '../lib/nethMark.ts'; +import { + SITE_TITLE, + SOCIAL_IMAGE_HEIGHT, + SOCIAL_IMAGE_WIDTH, + socialImageUrl, + withBase, +} from '../lib/site.ts'; interface Props { title: string; @@ -11,6 +19,8 @@ interface Props { const { title, description, surface = 'docs' } = Astro.props; const canonical = new URL(Astro.url.pathname, Astro.site); +const faviconHref = withBase(NETH_MARK_FILE); +const socialImage = socialImageUrl(); --- @@ -21,10 +31,22 @@ const canonical = new URL(Astro.url.pathname, Astro.site); {title} + + + + + + + + + + + + diff --git a/apps/web/src/lib/nethMark.ts b/apps/web/src/lib/nethMark.ts index 2adf200..99c2cff 100644 --- a/apps/web/src/lib/nethMark.ts +++ b/apps/web/src/lib/nethMark.ts @@ -1,20 +1,10 @@ -import { SITE_BASE, SITE_ORIGIN } from './site.ts'; +import { siteAssetUrl } from './site.ts'; export const NETH_MARK_VIEWBOX = '186 117 878 878'; export const NETH_MARK_PATH = 'M 628 117 L 393 544 L 608 995 L 858 552 Z M 628 255 L 689 410 L 627 643 L 737 489 L 769 551 L 611 843 L 476 546 Z'; export const NETH_MARK_FILE = 'neth.svg'; -function envString(key: string): string | undefined { - const env = import.meta.env as Record | undefined; - const value = env?.[key]; - return typeof value === 'string' && value.trim() ? value.trim() : undefined; -} - export function nethMarkUrl(): string { - const originRaw = envString('SITE') ?? SITE_ORIGIN; - const origin = originRaw.endsWith('/') ? originRaw : `${originRaw}/`; - const baseRaw = envString('BASE_URL') ?? SITE_BASE; - const base = baseRaw.endsWith('/') ? baseRaw : `${baseRaw}/`; - return new URL(`${base}${NETH_MARK_FILE}`, origin).href; + return siteAssetUrl(NETH_MARK_FILE); } diff --git a/apps/web/src/lib/site.test.ts b/apps/web/src/lib/site.test.ts new file mode 100644 index 0000000..a872891 --- /dev/null +++ b/apps/web/src/lib/site.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { NETH_MARK_FILE } from './nethMark.ts'; +import { + SOCIAL_IMAGE_FILE, + SOCIAL_IMAGE_HEIGHT, + SOCIAL_IMAGE_WIDTH, + siteAssetUrl, + socialImageUrl, + withBase, +} from './site.ts'; + +const here = dirname(fileURLToPath(import.meta.url)); +const publicDir = join(here, '../../public'); +const layoutPath = join(here, '../layouts/Layout.astro'); + +describe('site assets', () => { + it('publishes hero.png as the social preview image', () => { + const file = join(publicDir, SOCIAL_IMAGE_FILE); + assert.equal(existsSync(file), true, SOCIAL_IMAGE_FILE); + const png = readFileSync(file); + assert.equal(png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), true); + assert.equal(png.readUInt32BE(16), SOCIAL_IMAGE_WIDTH); + assert.equal(png.readUInt32BE(20), SOCIAL_IMAGE_HEIGHT); + assert.equal(socialImageUrl(), 'https://netherprotocol.xyz/hero.png'); + }); + + it('publishes the NETH mark as the favicon', () => { + assert.equal(existsSync(join(publicDir, NETH_MARK_FILE)), true, NETH_MARK_FILE); + assert.equal(siteAssetUrl(NETH_MARK_FILE), 'https://netherprotocol.xyz/neth.svg'); + assert.equal(withBase(NETH_MARK_FILE), '/neth.svg'); + }); + + it('wires favicon and social image tags in the document head', () => { + const layout = readFileSync(layoutPath, 'utf8'); + assert.ok(layout.includes('rel="icon"')); + assert.ok(layout.includes('type="image/svg+xml"')); + assert.ok(layout.includes('withBase(NETH_MARK_FILE)')); + assert.ok(layout.includes('property="og:image"')); + assert.ok(layout.includes('name="twitter:card"')); + assert.ok(layout.includes('summary_large_image')); + assert.ok(layout.includes('name="twitter:image"')); + assert.ok(layout.includes('socialImageUrl()')); + }); +}); diff --git a/apps/web/src/lib/site.ts b/apps/web/src/lib/site.ts index 55d6890..4c3ce76 100644 --- a/apps/web/src/lib/site.ts +++ b/apps/web/src/lib/site.ts @@ -58,15 +58,37 @@ export const RISK_WARNING_DETAILS = [ 'Capital is at high risk. Use Nether entirely at your own risk.', ] as const; -function githubRepoUrl(): string { +export const SOCIAL_IMAGE_FILE = 'hero.png'; +export const SOCIAL_IMAGE_WIDTH = 1122; +export const SOCIAL_IMAGE_HEIGHT = 1402; + +function envString(key: string): string | undefined { const env = import.meta.env as Record | undefined; - const fromEnv = env?.PUBLIC_GITHUB_REPO; - if (typeof fromEnv === 'string' && fromEnv.trim()) { - return fromEnv.trim().replace(/\/+$/, ''); + const value = env?.[key]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function githubRepoUrl(): string { + const fromEnv = envString('PUBLIC_GITHUB_REPO'); + if (fromEnv) { + return fromEnv.replace(/\/+$/, ''); } return GITHUB_REPO_FALLBACK; } +export function siteAssetUrl(pathname: string): string { + const originRaw = envString('SITE') ?? SITE_ORIGIN; + const origin = originRaw.endsWith('/') ? originRaw : `${originRaw}/`; + const baseRaw = envString('BASE_URL') ?? SITE_BASE; + const base = baseRaw.endsWith('/') ? baseRaw : `${baseRaw}/`; + const trimmed = pathname.replace(/^\/+/, ''); + return new URL(`${base}${trimmed}`, origin).href; +} + +export function socialImageUrl(): string { + return siteAssetUrl(SOCIAL_IMAGE_FILE); +} + export function withBase(pathname = ''): string { const env = import.meta.env as Record | undefined; const base = (typeof env?.BASE_URL === 'string' && env.BASE_URL) || SITE_BASE; diff --git a/apps/web/src/pages/index.astro b/apps/web/src/pages/index.astro index 9488da1..c935d18 100644 --- a/apps/web/src/pages/index.astro +++ b/apps/web/src/pages/index.astro @@ -12,11 +12,14 @@ import { SECONDARY_CTA, SITE_DESCRIPTION, SITE_TITLE, + SOCIAL_IMAGE_FILE, + SOCIAL_IMAGE_HEIGHT, + SOCIAL_IMAGE_WIDTH, withBase, } from '../lib/site'; const learnHref = withBase('learn'); -const heroSrc = withBase('hero.png'); +const heroSrc = withBase(SOCIAL_IMAGE_FILE); --- @@ -54,8 +57,8 @@ const heroSrc = withBase('hero.png');