fix: server-side OG/Twitter meta injection for crawler visibility
Some checks failed
Security Scan / semgrep (push) Has been cancelled
Security Scan / dependency-check (push) Has been cancelled
Build / build (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Lint & Type Check / lint (push) Has been cancelled
Test / test (18.x) (push) Has been cancelled
Test / test (20.x) (push) Has been cancelled

Crawlers (Twitter, Discord, Slack) don't execute JavaScript, so the
client-side SEO.tsx useEffect was invisible to them. Every page looked
identical — the hardcoded homepage defaults in index.html.

- node-build.ts: replace simple sendFile with async SSR meta middleware
  that injects per-route title/description/og:*/twitter:* before sending
  HTML. Static route map covers ~15 routes; dynamic lookup queries
  Supabase for /projects/:uuid (title, description, image_url) and
  /passport/:username (full_name, bio) so shared project/profile links
  render correct cards in Discord/Twitter/Slack unfurls.
- index.html: add twitter:site @aethexcorp; SSO.tsx useEffect still
  runs for browser tab updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AeThex 2026-04-12 14:30:47 +00:00
parent 29a32da48a
commit 34368e1dde
3 changed files with 349 additions and 14 deletions

View file

@ -1,9 +1,10 @@
import "./global.css";
import { useEffect } from "react";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { BrowserRouter, Routes, Route, useNavigate } from "react-router-dom";
import { useDiscordActivity } from "./contexts/DiscordActivityContext";
import { AuthProvider } from "./contexts/AuthContext";
import { Web3Provider } from "./contexts/Web3Context";
@ -161,14 +162,18 @@ import DeveloperPlatform from "./pages/dev-platform/DeveloperPlatform";
const queryClient = new QueryClient();
// When the app is accessed via staff.aethex.tech, auto-redirect to /staff
// so the subdomain works as a proper alias for the staff section.
// Detects staff.aethex.tech and navigates to /staff inside the SPA.
// Must be inside BrowserRouter so useNavigate works.
const StaffSubdomainRedirect = ({ children }: { children: React.ReactNode }) => {
const hostname = typeof window !== "undefined" ? window.location.hostname : "";
if (hostname === "staff.aethex.tech" && !window.location.pathname.startsWith("/staff")) {
window.location.replace("/staff" + window.location.pathname + window.location.search);
return null;
}
const navigate = useNavigate();
useEffect(() => {
if (
window.location.hostname === "staff.aethex.tech" &&
!window.location.pathname.startsWith("/staff")
) {
navigate("/staff", { replace: true });
}
}, [navigate]);
return <>{children}</>;
};

View file

@ -77,6 +77,7 @@
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@aethexcorp" />
<meta
name="twitter:title"
content="AeThex — Developer Platform, Projects, Community"

View file

@ -1,13 +1,14 @@
import path from "path";
import { fileURLToPath } from "url";
import { readFileSync } from "fs";
import { createServer } from "./index";
import * as express from "express";
import { adminSupabase } from "./supabase";
const app = createServer();
const port = process.env.PORT || 5000;
const host = "0.0.0.0";
// In production, serve the built SPA files
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const distPath = path.join(__dirname, "../spa");
@ -15,23 +16,351 @@ const distPath = path.join(__dirname, "../spa");
// Serve static files
app.use(express.static(distPath));
// Handle React Router - serve index.html for all non-API routes
app.get("*", (req, res) => {
// Don't serve index.html for API routes
// ── SSR Meta Injection ────────────────────────────────────────────────────────
const BASE_URL = "https://aethex.dev";
const DEFAULT_OG_IMAGE =
"https://docs.aethex.tech/~gitbook/image?url=https%3A%2F%2F1143808467-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Forganizations%252FDhUg3jal6kdpG645FzIl%252Fsites%252Fsite_HeOmR%252Flogo%252FqxDYz8Oj2SnwUTa8t3UB%252FAeThex%2520Origin%2520logo.png%3Falt%3Dmedia%26token%3D200e8ea2-0129-4cbe-b516-4a53f60c512b&width=1200&dpr=1&quality=100&sign=6c7576ce&sv=2";
type RouteMeta = {
title: string;
description: string;
image?: string;
type?: string;
};
/** Static route overrides — matched in order, first match wins. */
const STATIC_META: Array<{ pattern: RegExp; meta: RouteMeta }> = [
{
pattern: /^\/$/,
meta: {
title: "AeThex | Developer Platform for Builders, Creators & Innovation",
description:
"AeThex: an advanced development platform and community for builders. Collaborate on projects, learn, and ship innovation.",
},
},
{
pattern: /^\/projects\/?$/,
meta: {
title: "AeThex | Projects",
description:
"Explore open-source and community projects built on the AeThex platform.",
},
},
{
pattern: /^\/projects\/new/,
meta: {
title: "AeThex | New Project",
description: "Start a new project on AeThex.",
},
},
{
pattern: /^\/gameforge/,
meta: {
title: "AeThex | GameForge Studio",
description:
"GameForge — AeThex's game development studio for indie game creators. Build, manage, and ship games.",
},
},
{
pattern: /^\/ethos/,
meta: {
title: "AeThex | Ethos Guild",
description:
"Ethos Guild — collaborative music creation, licensing, and creative work on AeThex.",
},
},
{
pattern: /^\/dev-platform/,
meta: {
title: "AeThex | Developer Platform",
description:
"Build with AeThex APIs. Documentation, SDKs, examples, and developer tools for modern builders.",
},
},
{
pattern: /^\/feed/,
meta: {
title: "AeThex | Community Feed",
description: "What's happening in the AeThex builder community.",
},
},
{
pattern: /^\/login/,
meta: {
title: "AeThex | Sign In",
description:
"Sign in to your AeThex account to access projects, community, and more.",
},
},
{
pattern: /^\/register/,
meta: {
title: "AeThex | Create Account",
description:
"Join AeThex and start building, learning, and collaborating with the community.",
},
},
{
pattern: /^\/dashboard/,
meta: {
title: "AeThex | Dashboard",
description:
"Your personal AeThex dashboard — manage projects, track progress, and connect.",
},
},
{
pattern: /^\/passport\/me$/,
meta: {
title: "AeThex | My Passport",
description: "View your AeThex developer passport and profile.",
},
},
{
pattern: /^\/docs/,
meta: {
title: "AeThex | Documentation",
description: "Guides, API references, and developer resources for AeThex.",
},
},
{
pattern: /^\/pricing/,
meta: {
title: "AeThex | Pricing",
description:
"Simple, transparent pricing for AeThex — individuals, teams, and enterprises.",
},
},
{
pattern: /^\/about/,
meta: {
title: "AeThex | About",
description:
"Learn about AeThex — our mission, team, and the technology we build.",
},
},
{
pattern: /^\/blog/,
meta: {
title: "AeThex | Blog",
description:
"News, tutorials, and announcements from the AeThex team and community.",
},
},
];
// UUID pattern
const PROJECT_UUID_RE =
/^\/projects\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
// Passport: /passport/<username> (not "me", handled above)
const PASSPORT_USER_RE = /^\/passport\/([^/]+)$/;
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/**
* Replaces meta tag content values in the HTML template.
* Handles both inline and multi-line attribute formats (Vite preserves them).
*/
function injectMeta(
html: string,
meta: RouteMeta & { url: string }
): string {
const {
title,
description,
url,
image = DEFAULT_OG_IMAGE,
type = "website",
} = meta;
const t = escHtml(title);
const d = escHtml(description);
const u = escHtml(url);
const img = escHtml(image);
return (
html
// <title>
.replace(/<title>[^<]*<\/title>/, `<title>${t}</title>`)
// meta name="description"
.replace(
/(<meta\s+name="description"\s+content=")[^"]*"/,
`$1${d}"`
)
// og:title
.replace(
/(<meta\s+property="og:title"\s+content=")[^"]*"/,
`$1${t}"`
)
// og:description
.replace(
/(<meta\s+property="og:description"\s+content=")[^"]*"/,
`$1${d}"`
)
// og:url
.replace(
/(<meta\s+property="og:url"\s+content=")[^"]*"/,
`$1${u}"`
)
// og:type
.replace(
/(<meta\s+property="og:type"\s+content=")[^"]*"/,
`$1${type}"`
)
// og:image (first occurrence — the main image tag, not og:image:width/height)
.replace(
/(<meta\s+property="og:image"\s+content=")[^"]*"/,
`$1${img}"`
)
// twitter:title
.replace(
/(<meta\s+name="twitter:title"\s+content=")[^"]*"/,
`$1${t}"`
)
// twitter:description
.replace(
/(<meta\s+name="twitter:description"\s+content=")[^"]*"/,
`$1${d}"`
)
// twitter:image
.replace(
/(<meta\s+name="twitter:image"\s+content=")[^"]*"/,
`$1${img}"`
)
// canonical link
.replace(
/(<link\s+rel="canonical"\s+href=")[^"]*"/,
`$1${u}"`
)
);
}
/** Resolve per-route meta, fetching from DB for dynamic routes. */
async function resolveRouteMeta(
pathname: string
): Promise<RouteMeta & { url: string }> {
const url = `${BASE_URL}${pathname}`;
// Dynamic: project detail page
const projectMatch = PROJECT_UUID_RE.exec(pathname);
if (projectMatch && adminSupabase) {
const projectId = projectMatch[1];
try {
const { data: project } = await (adminSupabase as any)
.from("projects")
.select("title, description, image_url, status")
.eq("id", projectId)
.maybeSingle();
if (project) {
const desc = project.description
? String(project.description).slice(0, 160)
: `View the ${project.title} project on AeThex.`;
return {
title: `${project.title} — AeThex Project`,
description: desc,
image: project.image_url || DEFAULT_OG_IMAGE,
type: "article",
url,
};
}
} catch {
// fall through to default
}
}
// Dynamic: public passport / profile page
const passportMatch = PASSPORT_USER_RE.exec(pathname);
if (passportMatch && adminSupabase) {
const username = passportMatch[1];
try {
const { data: profile } = await adminSupabase
.from("user_profiles")
.select("username, full_name, avatar_url, bio")
.eq("username", username)
.maybeSingle();
if (profile) {
const displayName =
(profile as any).full_name || profile.username || username;
const bio = (profile as any).bio;
const desc = bio
? String(bio).slice(0, 160)
: `View ${displayName}'s developer passport and projects on AeThex.`;
return {
title: `${displayName} — AeThex Passport`,
description: desc,
url,
};
}
} catch {
// fall through
}
}
// Static route map
for (const { pattern, meta } of STATIC_META) {
if (pattern.test(pathname)) {
return { ...meta, url };
}
}
// Default fallback
return {
title: "AeThex | Developer Platform for Builders, Creators & Innovation",
description:
"AeThex: an advanced development platform and community for builders. Collaborate on projects, learn, and ship innovation.",
url,
};
}
// Cache the HTML template (reset on read error so a rebuild is picked up on restart)
let htmlTemplate: string | null = null;
function getTemplate(): string {
if (!htmlTemplate) {
htmlTemplate = readFileSync(path.join(distPath, "index.html"), "utf-8");
}
return htmlTemplate;
}
// ── Route Handler ─────────────────────────────────────────────────────────────
app.get("*", async (req, res) => {
if (req.path.startsWith("/api/") || req.path.startsWith("/health")) {
return res.status(404).json({ error: "API endpoint not found" });
}
res.sendFile(path.join(distPath, "index.html"));
try {
const template = getTemplate();
const meta = await resolveRouteMeta(req.path);
const html = injectMeta(template, meta);
res.setHeader("Content-Type", "text/html; charset=utf-8");
// Crawlers should not cache — browsers can
res.setHeader(
"Cache-Control",
"public, max-age=60, stale-while-revalidate=300"
);
res.send(html);
} catch (err) {
console.error("[SSR Meta] Error:", err);
// Fallback: send unmodified file
res.sendFile(path.join(distPath, "index.html"));
}
});
// ── Server ────────────────────────────────────────────────────────────────────
app.listen(Number(port), host, () => {
console.log(`🚀 AeThex server running on ${host}:${port}`);
console.log(`📱 Frontend: http://${host}:${port}`);
console.log(`🔧 API: http://${host}:${port}/api`);
});
// Graceful shutdown
process.on("SIGTERM", () => {
console.log("🛑 Received SIGTERM, shutting down gracefully");
process.exit(0);