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 { 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}</>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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, "&")
|
||||
.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")) {
|
||||
return res.status(404).json({ error: "API endpoint not found" });
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue