From 34368e1dde2909ebd80a6fbe3811626057dfd9bf Mon Sep 17 00:00:00 2001 From: AeThex Date: Sun, 12 Apr 2026 14:30:47 +0000 Subject: [PATCH] fix: server-side OG/Twitter meta injection for crawler visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/App.tsx | 21 ++- index.html | 1 + server/node-build.ts | 341 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 349 insertions(+), 14 deletions(-) diff --git a/client/App.tsx b/client/App.tsx index a233c209..44386c20 100644 --- a/client/App.tsx +++ b/client/App.tsx @@ -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}; }; diff --git a/index.html b/index.html index 50d62c1c..8bb827e9 100644 --- a/index.html +++ b/index.html @@ -77,6 +77,7 @@ + { - // 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/ (not "me", handled above) +const PASSPORT_USER_RE = /^\/passport\/([^/]+)$/; + +function escHtml(s: string): string { + return s + .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 + // + .replace(/<title>[^<]*<\/title>/, `<title>${t}`) + // meta name="description" + .replace( + /( { + 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);