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
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:
parent
29a32da48a
commit
34368e1dde
3 changed files with 349 additions and 14 deletions
|
|
@ -1,9 +1,10 @@
|
||||||
import "./global.css";
|
import "./global.css";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 { useDiscordActivity } from "./contexts/DiscordActivityContext";
|
||||||
import { AuthProvider } from "./contexts/AuthContext";
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
import { Web3Provider } from "./contexts/Web3Context";
|
import { Web3Provider } from "./contexts/Web3Context";
|
||||||
|
|
@ -161,14 +162,18 @@ import DeveloperPlatform from "./pages/dev-platform/DeveloperPlatform";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
// When the app is accessed via staff.aethex.tech, auto-redirect to /staff
|
// Detects staff.aethex.tech and navigates to /staff inside the SPA.
|
||||||
// so the subdomain works as a proper alias for the staff section.
|
// Must be inside BrowserRouter so useNavigate works.
|
||||||
const StaffSubdomainRedirect = ({ children }: { children: React.ReactNode }) => {
|
const StaffSubdomainRedirect = ({ children }: { children: React.ReactNode }) => {
|
||||||
const hostname = typeof window !== "undefined" ? window.location.hostname : "";
|
const navigate = useNavigate();
|
||||||
if (hostname === "staff.aethex.tech" && !window.location.pathname.startsWith("/staff")) {
|
useEffect(() => {
|
||||||
window.location.replace("/staff" + window.location.pathname + window.location.search);
|
if (
|
||||||
return null;
|
window.location.hostname === "staff.aethex.tech" &&
|
||||||
}
|
!window.location.pathname.startsWith("/staff")
|
||||||
|
) {
|
||||||
|
navigate("/staff", { replace: true });
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@
|
||||||
|
|
||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:site" content="@aethexcorp" />
|
||||||
<meta
|
<meta
|
||||||
name="twitter:title"
|
name="twitter:title"
|
||||||
content="AeThex — Developer Platform, Projects, Community"
|
content="AeThex — Developer Platform, Projects, Community"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
import { createServer } from "./index";
|
import { createServer } from "./index";
|
||||||
import * as express from "express";
|
import * as express from "express";
|
||||||
|
import { adminSupabase } from "./supabase";
|
||||||
|
|
||||||
const app = createServer();
|
const app = createServer();
|
||||||
const port = process.env.PORT || 5000;
|
const port = process.env.PORT || 5000;
|
||||||
const host = "0.0.0.0";
|
const host = "0.0.0.0";
|
||||||
|
|
||||||
// In production, serve the built SPA files
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const distPath = path.join(__dirname, "../spa");
|
const distPath = path.join(__dirname, "../spa");
|
||||||
|
|
@ -15,23 +16,351 @@ const distPath = path.join(__dirname, "../spa");
|
||||||
// Serve static files
|
// Serve static files
|
||||||
app.use(express.static(distPath));
|
app.use(express.static(distPath));
|
||||||
|
|
||||||
// Handle React Router - serve index.html for all non-API routes
|
// ── SSR Meta Injection ────────────────────────────────────────────────────────
|
||||||
app.get("*", (req, res) => {
|
|
||||||
// Don't serve index.html for API routes
|
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, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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")) {
|
if (req.path.startsWith("/api/") || req.path.startsWith("/health")) {
|
||||||
return res.status(404).json({ error: "API endpoint not found" });
|
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, () => {
|
app.listen(Number(port), host, () => {
|
||||||
console.log(`🚀 AeThex server running on ${host}:${port}`);
|
console.log(`🚀 AeThex server running on ${host}:${port}`);
|
||||||
console.log(`📱 Frontend: http://${host}:${port}`);
|
console.log(`📱 Frontend: http://${host}:${port}`);
|
||||||
console.log(`🔧 API: http://${host}:${port}/api`);
|
console.log(`🔧 API: http://${host}:${port}/api`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
console.log("🛑 Received SIGTERM, shutting down gracefully");
|
console.log("🛑 Received SIGTERM, shutting down gracefully");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue