mirror of
https://github.com/jakejarvis/domainstack.io.git
synced 2026-09-11 05:05:34 -04:00
refactor: centralize SEO metadata management
- Replaced hardcoded metadata in various pages with a centralized `createMetadata` function for consistency and maintainability. - Introduced `rootMetadata` and `notFoundMetadata` for default SEO settings. - Added new Open Graph image generation functionality in `opengraph-image.tsx`. - Updated existing pages to utilize the new SEO structure, ensuring proper metadata handling across the application.
This commit is contained in:
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/universal:5",
|
||||
"postCreateCommand": ". ${NVM_DIR}/nvm.sh && nvm install && corepack enable && CI=true pnpm install",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"oxc.oxc-vscode",
|
||||
"vitest.explorer",
|
||||
"vercel.turbo-vsc"
|
||||
],
|
||||
"settings": {
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
}
|
||||
},
|
||||
"forwardPorts": [3000],
|
||||
"containerEnv": {
|
||||
"COREPACK_ENABLE_DOWNLOAD_PROMPT": "0",
|
||||
"NEXT_TELEMETRY_DISABLED": "1"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"next-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "next-devtools-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,39 +34,25 @@
|
||||
|
||||
- **Next.js 16** (App Router), **React 19**, **TypeScript**
|
||||
- **Tailwind CSS v4** + [**Base UI**](https://base-ui.com/)
|
||||
- **tRPC** + **TanStack Query** & **TanStack Table**
|
||||
- **Postgres** (PlanetScale) + **Drizzle ORM** + **Upstash Redis** (rate limiting)
|
||||
- **Better Auth** (OAuth)
|
||||
- **Polar** (subscriptions)
|
||||
- [**Workflow DevKit**](https://useworkflow.dev/) (background jobs)
|
||||
- [**AI SDK**](https://ai-sdk.dev/) + [**AI Gateway**](https://vercel.com/ai-gateway)
|
||||
- **Resend** + **React Email**
|
||||
- [**tRPC**](https://trpc.io/) + [**TanStack Query**](https://tanstack.com/query/latest) & [**TanStack Table**](https://tanstack.com/table/latest)
|
||||
- [**PlanetScale Postgres**](https://planetscale.com/postgres) + [**Drizzle**](https://orm.drizzle.team/) + [**Upstash Redis**](https://upstash.com/) (rate limiting)
|
||||
- [**Better Auth**](https://www.better-auth.com) (OAuth)
|
||||
- [**Polar**](https://polar.sh/) (subscriptions)
|
||||
- [**Workflow SDK**](https://workflow-sdk.dev/) (background jobs)
|
||||
- [**AI SDK**](https://ai-sdk.dev/) + [**Vercel AI Gateway**](https://vercel.com/ai-gateway) (Stacky bot)
|
||||
- [**Resend**](https://resend.com/) (email notifications)
|
||||
- [**mapcn**](https://mapcn.vercel.app/) + [**CARTO Basemaps**](https://docs.carto.com/faqs/carto-basemaps) (web maps)
|
||||
- [**Logo.dev**](https://www.logo.dev) (provider icons)
|
||||
- [**IPLocate.io**](https://www.iplocate.io/) (geolocation)
|
||||
- [**PostHog**](https://posthog.com/) (telemetry)
|
||||
- **Vercel** (Edge Config, Blob Storage)
|
||||
- [**mapcn**](https://mapcn.vercel.app/) + [**CARTO Basemaps**](https://docs.carto.com/faqs/carto-basemaps)
|
||||
- [**Logo.dev**](https://www.logo.dev)
|
||||
- [**IPLocate.io**](https://www.iplocate.io/)
|
||||
- **PostHog** (analytics)
|
||||
- **Turborepo** (monorepo)
|
||||
- **Vitest** + **Playwright** (testing), **oxlint/oxfmt** (linting)
|
||||
|
||||
## Project Structure
|
||||
|
||||
This is a **[Turborepo](https://turborepo.dev/docs) monorepo**:
|
||||
|
||||
```
|
||||
domainstack.io/
|
||||
├── apps/
|
||||
│ └── web/ # Next.js application
|
||||
├── packages/
|
||||
│ ├── constants/ # Shared constants (enums, TTLs, validation)
|
||||
│ ├── types/ # Shared TypeScript types
|
||||
│ ├── typescript-config/ # Shared TypeScript configs
|
||||
│ └── ui/ # Shared UI primitives
|
||||
└── turbo.json # Turborepo task configuration
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
This is a **[Turborepo](https://turborepo.dev/docs) monorepo**.
|
||||
|
||||
### 1. Clone & install
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { LoginContent } from "@/components/auth/login-content";
|
||||
import { createMetadata } from "@/lib/seo";
|
||||
import { Card } from "@domainstack/ui/card";
|
||||
import { cn } from "@domainstack/ui/utils";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
export const metadata: Metadata = createMetadata({
|
||||
path: "/login",
|
||||
title: "Sign In",
|
||||
description: "Sign in to track your domains and receive health alerts.",
|
||||
};
|
||||
});
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { AnnouncementPill } from "@/components/landing/announcement-pill";
|
||||
@@ -5,6 +6,11 @@ import { HomeHero } from "@/components/landing/home-hero";
|
||||
import { HomeSearchClient } from "@/components/search/home-search-client";
|
||||
import { DomainSuggestions } from "@/components/search/home-search-suggestions";
|
||||
import { HomeSearchSuggestionsSkeleton } from "@/components/search/home-search-suggestions-skeleton";
|
||||
import { createMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = createMetadata({
|
||||
path: "/",
|
||||
});
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { createMetadata } from "@/lib/seo";
|
||||
import { PLAN_QUOTAS } from "@domainstack/constants";
|
||||
import { PRO_TIER_INFO } from "@domainstack/polar/products";
|
||||
import {
|
||||
@@ -10,11 +11,12 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@domainstack/ui/accordion";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
export const metadata: Metadata = createMetadata({
|
||||
path: "/help",
|
||||
title: "Help & FAQ",
|
||||
description:
|
||||
"Get answers to common questions about Domainstack, domain tracking, notifications, and more.",
|
||||
};
|
||||
});
|
||||
|
||||
const faqSections = [
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
|
||||
import { BetaBadge } from "@/components/beta-badge";
|
||||
import { createMetadata } from "@/lib/seo";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -18,10 +19,11 @@ import {
|
||||
import { Badge } from "@domainstack/ui/badge";
|
||||
import { CodeBlock } from "@domainstack/ui/code-block";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
export const metadata: Metadata = createMetadata({
|
||||
path: "/mcp",
|
||||
title: "MCP (Beta)",
|
||||
description: "Connect AI assistants like Claude to Domainstack for domain intelligence lookups.",
|
||||
};
|
||||
});
|
||||
|
||||
const MCP_URL = `${process.env.NEXT_PUBLIC_BASE_URL}/api/transport/mcp`;
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
import { createMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = createMetadata({
|
||||
path: "/privacy",
|
||||
title: "Privacy Policy",
|
||||
description: "Learn how Domainstack collects, uses, and protects your personal information.",
|
||||
};
|
||||
});
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
import { createMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = createMetadata({
|
||||
path: "/terms",
|
||||
title: "Terms of Service",
|
||||
description: "Terms and conditions for using Domainstack's domain intelligence platform.",
|
||||
};
|
||||
});
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,8 @@ import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import { DomainReportClient } from "@/components/domain/report-client";
|
||||
import { toRegistrableDomain } from "@/lib/normalize-domain";
|
||||
import { OG_IMAGE_SIZE } from "@/lib/og-utils";
|
||||
import { createMetadata, notFoundMetadata } from "@/lib/seo";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -14,40 +16,31 @@ export async function generateMetadata({
|
||||
|
||||
const registrable = toRegistrableDomain(decoded);
|
||||
if (!registrable) {
|
||||
// workaround, should match metadata from not-found.tsx
|
||||
return {
|
||||
title: "Not Found",
|
||||
description: "The page you're looking for doesn't exist.",
|
||||
};
|
||||
return notFoundMetadata;
|
||||
}
|
||||
|
||||
return {
|
||||
const imageUrl = `/api/og?domain=${encodeURIComponent(registrable)}`;
|
||||
|
||||
return createMetadata({
|
||||
path: `/${registrable}`,
|
||||
title: {
|
||||
absolute: `${registrable} — Domain Report`,
|
||||
},
|
||||
description: `Domainstack report for ${registrable}: WHOIS lookup, DNS & SSL scan, HTTP headers, hosting & email provider data, and SEO metadata.`,
|
||||
alternates: {
|
||||
canonical: `/${registrable}`,
|
||||
},
|
||||
openGraph: {
|
||||
title: `${registrable} — Domain Report`,
|
||||
description: `Domainstack report for ${registrable}: WHOIS lookup, DNS & SSL scan, HTTP headers, hosting & email provider data, and SEO metadata.`,
|
||||
images: [
|
||||
{
|
||||
url: `/api/og?domain=${encodeURIComponent(registrable)}`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
url: imageUrl,
|
||||
width: OG_IMAGE_SIZE.width,
|
||||
height: OG_IMAGE_SIZE.height,
|
||||
alt: `Domainstack — Domain Report for ${registrable}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: `${registrable} — Domain Report`,
|
||||
description: `Domainstack report for ${registrable}: WHOIS lookup, DNS & SSL scan, HTTP headers, hosting & email provider data, and SEO metadata.`,
|
||||
images: [`/api/og?domain=${encodeURIComponent(registrable)}`],
|
||||
images: [imageUrl],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default async function DomainPage({ params }: { params: Promise<{ domain: string }> }) {
|
||||
|
||||
@@ -5,13 +5,13 @@ import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { Logo } from "@/components/logo";
|
||||
import { toRegistrableDomain } from "@/lib/normalize-domain";
|
||||
import { hexToRGBA, loadGoogleFont } from "@/lib/og-utils";
|
||||
import { hexToRGBA, loadGoogleFont, OG_BACKGROUND_IMAGE, OG_IMAGE_SIZE } from "@/lib/og-utils";
|
||||
import { createCaller } from "@/server/routers/_app";
|
||||
import { createLogger } from "@domainstack/logger";
|
||||
import type { ProviderRef } from "@domainstack/types";
|
||||
import { normalizeDomainInput } from "@domainstack/utils/domain";
|
||||
|
||||
const SIZE = { width: 1200, height: 630 };
|
||||
const SIZE = OG_IMAGE_SIZE;
|
||||
|
||||
const logger = createLogger({ source: "api/og" });
|
||||
|
||||
@@ -154,8 +154,7 @@ export async function GET(request: NextRequest) {
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
backgroundImage:
|
||||
"linear-gradient(346deg, rgba(55, 55, 55,0.04) 0%, rgba(55, 55, 55,0.04) 22%,rgba(140, 140, 140,0.04) 22%, rgba(140, 140, 140,0.04) 69%,rgba(225, 225, 225,0.04) 69%, rgba(225, 225, 225,0.04) 100%),linear-gradient(31deg, rgba(55, 55, 55,0.04) 0%, rgba(55, 55, 55,0.04) 42%,rgba(140, 140, 140,0.04) 42%, rgba(140, 140, 140,0.04) 85%,rgba(225, 225, 225,0.04) 85%, rgba(225, 225, 225,0.04) 100%),linear-gradient(55deg, rgba(55, 55, 55,0.04) 0%, rgba(55, 55, 55,0.04) 13%,rgba(140, 140, 140,0.04) 13%, rgba(140, 140, 140,0.04) 72%,rgba(225, 225, 225,0.04) 72%, rgba(225, 225, 225,0.04) 100%),linear-gradient(90deg, rgb(0,0,0),rgb(0,0,0))",
|
||||
backgroundImage: OG_BACKGROUND_IMAGE,
|
||||
fontFamily: "Geist", // must match fonts[].name
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { AddDomainPageClient } from "@/components/dashboard/add-domain/add-domain-page-client";
|
||||
import { AddDomainSkeleton } from "@/components/dashboard/add-domain/add-domain-skeleton";
|
||||
import { createMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = createMetadata({
|
||||
path: "/dashboard/add-domain",
|
||||
title: "Add Domain",
|
||||
description: "Add a domain to track registration, DNS, SSL, and hosting changes.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default async function AddDomainPage({
|
||||
searchParams,
|
||||
|
||||
@@ -5,16 +5,18 @@ import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { DashboardSkeleton } from "@/components/dashboard/dashboard-skeleton";
|
||||
import { createMetadata } from "@/lib/seo";
|
||||
import { auth } from "@domainstack/auth/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
export const metadata: Metadata = createMetadata({
|
||||
path: "/dashboard",
|
||||
title: "Dashboard",
|
||||
description: "Manage your tracked domains and notification settings.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
async function ProtectedDashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
// Server-side auth check - requires runtime data (headers)
|
||||
|
||||
+2
-12
@@ -9,21 +9,11 @@ import { CookiePromptGeofenced } from "@/components/consent/cookie-prompt-geofen
|
||||
import { AppFooter } from "@/components/layout/app-footer";
|
||||
import { AppHeader } from "@/components/layout/app-header";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { rootMetadata } from "@/lib/seo";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Domainstack — Domain Intelligence Made Easy",
|
||||
template: "%s — Domainstack",
|
||||
},
|
||||
description:
|
||||
"Instant lookups for WHOIS, DNS, hosting, certificates, SEO and more, plus free domain tracking and change alerts.",
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"),
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
};
|
||||
export const metadata: Metadata = rootMetadata;
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { IconFileX } from "@tabler/icons-react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { SearchClient } from "@/components/search/search-client";
|
||||
import { notFoundMetadata } from "@/lib/seo";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
@@ -11,10 +12,7 @@ import {
|
||||
EmptyTitle,
|
||||
} from "@domainstack/ui/empty";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Not Found",
|
||||
description: "The page you're looking for doesn't exist.",
|
||||
};
|
||||
export const metadata: Metadata = notFoundMetadata;
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
import { Logo } from "@/components/logo";
|
||||
import { loadGoogleFont, OG_BACKGROUND_IMAGE, OG_IMAGE_SIZE } from "@/lib/og-utils";
|
||||
import { SITE_DESCRIPTION, SITE_NAME, SITE_TAGLINE, SITE_TITLE } from "@/lib/seo";
|
||||
|
||||
export const alt = SITE_TITLE;
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = "image/png";
|
||||
|
||||
export default async function Image() {
|
||||
const [geistRegularFont, geistSemiBoldFont] = await Promise.all([
|
||||
loadGoogleFont("Geist", 400),
|
||||
loadGoogleFont("Geist", 600),
|
||||
]);
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
backgroundImage: OG_BACKGROUND_IMAGE,
|
||||
fontFamily: "Geist",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: "56px 64px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<Logo
|
||||
width={48}
|
||||
height={48}
|
||||
style={{
|
||||
color: "#EAEFF7",
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
color: "#EAEFF7",
|
||||
letterSpacing: 0.3,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{SITE_NAME}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: "#AAB3C2" }}>{SITE_TAGLINE}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 64,
|
||||
lineHeight: 1.1,
|
||||
fontWeight: 600,
|
||||
color: "#EAEFF7",
|
||||
letterSpacing: -1.2,
|
||||
textShadow: "0 2px 16px rgba(0,0,0,0.35)",
|
||||
maxWidth: 980,
|
||||
}}
|
||||
>
|
||||
Inspect any domain.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 26,
|
||||
lineHeight: 1.5,
|
||||
color: "#AAB3C2",
|
||||
maxWidth: 900,
|
||||
}}
|
||||
>
|
||||
{SITE_DESCRIPTION}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderTop: "1px solid rgba(255,255,255,0.08)",
|
||||
paddingTop: 18,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
background: "rgba(0, 212, 255, 1)",
|
||||
boxShadow: "0 0 20px rgba(0, 212, 255, 0.66)",
|
||||
}}
|
||||
/>
|
||||
<div style={{ color: "#AAB3C2", fontSize: 18 }}>WHOIS, DNS, SSL, hosting & SEO</div>
|
||||
</div>
|
||||
<div style={{ color: "#EAEFF7", fontSize: 18 }}>domainstack.io</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
...size,
|
||||
fonts: [
|
||||
{
|
||||
name: "Geist",
|
||||
data: geistRegularFont,
|
||||
style: "normal",
|
||||
weight: 400,
|
||||
},
|
||||
{
|
||||
name: "Geist",
|
||||
data: geistSemiBoldFont,
|
||||
style: "normal",
|
||||
weight: 600,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -8,18 +8,20 @@ import {
|
||||
SettingsSkeletonPanels,
|
||||
SettingsSkeletonTabsList,
|
||||
} from "@/components/settings/settings-skeleton";
|
||||
import { createMetadata } from "@/lib/seo";
|
||||
import { auth } from "@domainstack/auth/server";
|
||||
import { Card } from "@domainstack/ui/card";
|
||||
import { Skeleton } from "@domainstack/ui/skeleton";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
export const metadata: Metadata = createMetadata({
|
||||
path: "/settings",
|
||||
title: "Settings",
|
||||
description: "Manage your subscription and notification preferences.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
async function ProtectedSettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
// Server-side auth check - requires runtime data (headers)
|
||||
|
||||
@@ -6,7 +6,7 @@ export function HostingMapSkeleton() {
|
||||
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
|
||||
<div className="flex items-center gap-1.5 text-[13px] text-muted-foreground">
|
||||
<Spinner className="size-4" />
|
||||
Loading map...
|
||||
Loading map…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -350,7 +350,7 @@ export function Screenshot({
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner />
|
||||
Taking screenshot...
|
||||
Taking screenshot…
|
||||
</>
|
||||
) : blocked ? (
|
||||
<>
|
||||
|
||||
@@ -82,7 +82,7 @@ const DefaultLoader = () => (
|
||||
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
|
||||
<div className="flex items-center gap-1.5 text-[13px] text-muted-foreground">
|
||||
<Spinner className="size-4" />
|
||||
Loading map...
|
||||
Loading map…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { cacheLife } from "next/cache";
|
||||
|
||||
export const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const;
|
||||
|
||||
/** Dark geometric background used by default and per-domain OG images. */
|
||||
export const OG_BACKGROUND_IMAGE =
|
||||
"linear-gradient(346deg, rgba(55, 55, 55,0.04) 0%, rgba(55, 55, 55,0.04) 22%,rgba(140, 140, 140,0.04) 22%, rgba(140, 140, 140,0.04) 69%,rgba(225, 225, 225,0.04) 69%, rgba(225, 225, 225,0.04) 100%),linear-gradient(31deg, rgba(55, 55, 55,0.04) 0%, rgba(55, 55, 55,0.04) 42%,rgba(140, 140, 140,0.04) 42%, rgba(140, 140, 140,0.04) 85%,rgba(225, 225, 225,0.04) 85%, rgba(225, 225, 225,0.04) 100%),linear-gradient(55deg, rgba(55, 55, 55,0.04) 0%, rgba(55, 55, 55,0.04) 13%,rgba(140, 140, 140,0.04) 13%, rgba(140, 140, 140,0.04) 72%,rgba(225, 225, 225,0.04) 72%, rgba(225, 225, 225,0.04) 100%),linear-gradient(90deg, rgb(0,0,0),rgb(0,0,0))";
|
||||
|
||||
// Load a Google Font from the Google Fonts API
|
||||
// Adapted from https://github.com/brianlovin/briOS/blob/f72dc33a11194de45c80337b22be4560da62ad7e/src/lib/og-utils.tsx#L32
|
||||
export async function loadGoogleFont(font: string, weight: number): Promise<ArrayBuffer> {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SITE_NAME, SITE_TITLE, createMetadata, notFoundMetadata, rootMetadata } from "./seo";
|
||||
|
||||
describe("createMetadata", () => {
|
||||
it("sets canonical and og:url from the page path", () => {
|
||||
const metadata = createMetadata({
|
||||
path: "/help",
|
||||
title: "Help & FAQ",
|
||||
description: "Answers to common questions.",
|
||||
});
|
||||
|
||||
expect(metadata.title).toBe("Help & FAQ");
|
||||
expect(metadata.description).toBe("Answers to common questions.");
|
||||
expect(metadata.alternates?.canonical).toBe("/help");
|
||||
expect(metadata.openGraph).toMatchObject({
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
siteName: SITE_NAME,
|
||||
url: "/help",
|
||||
title: "Help & FAQ",
|
||||
description: "Answers to common questions.",
|
||||
});
|
||||
expect(metadata.twitter).toMatchObject({
|
||||
card: "summary_large_image",
|
||||
title: "Help & FAQ",
|
||||
description: "Answers to common questions.",
|
||||
});
|
||||
});
|
||||
|
||||
it("copies absolute titles onto Open Graph and Twitter", () => {
|
||||
const metadata = createMetadata({
|
||||
path: "/example.com",
|
||||
title: { absolute: "example.com — Domain Report" },
|
||||
description: "A domain report.",
|
||||
});
|
||||
|
||||
expect(metadata.openGraph).toMatchObject({
|
||||
title: "example.com — Domain Report",
|
||||
description: "A domain report.",
|
||||
});
|
||||
expect(metadata.twitter).toMatchObject({
|
||||
title: "example.com — Domain Report",
|
||||
description: "A domain report.",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets page-level Open Graph and Twitter fields override defaults", () => {
|
||||
const metadata = createMetadata({
|
||||
path: "/example.com",
|
||||
title: { absolute: "example.com — Domain Report" },
|
||||
description: "A domain report.",
|
||||
openGraph: {
|
||||
title: "Custom OG title",
|
||||
description: "Custom OG description",
|
||||
images: [{ url: "/api/og?domain=example.com" }],
|
||||
},
|
||||
twitter: {
|
||||
title: "Custom Twitter title",
|
||||
images: ["/api/og?domain=example.com"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(metadata.openGraph).toMatchObject({
|
||||
siteName: SITE_NAME,
|
||||
type: "website",
|
||||
url: "/example.com",
|
||||
title: "Custom OG title",
|
||||
description: "Custom OG description",
|
||||
images: [{ url: "/api/og?domain=example.com" }],
|
||||
});
|
||||
expect(metadata.twitter).toMatchObject({
|
||||
card: "summary_large_image",
|
||||
title: "Custom Twitter title",
|
||||
description: "A domain report.",
|
||||
images: ["/api/og?domain=example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("omits canonical and og:url when no path is provided", () => {
|
||||
const metadata = createMetadata({
|
||||
title: "Not Found",
|
||||
robots: { index: false, follow: false },
|
||||
});
|
||||
|
||||
expect(metadata.alternates).toBeUndefined();
|
||||
expect(metadata.openGraph).toMatchObject({
|
||||
siteName: SITE_NAME,
|
||||
title: "Not Found",
|
||||
});
|
||||
expect(metadata.openGraph).not.toHaveProperty("url");
|
||||
});
|
||||
});
|
||||
|
||||
describe("root metadata", () => {
|
||||
it("does not pin a site-wide canonical", () => {
|
||||
expect(rootMetadata.applicationName).toBe(SITE_NAME);
|
||||
expect(rootMetadata.title).toEqual({
|
||||
default: SITE_TITLE,
|
||||
template: `%s — ${SITE_NAME}`,
|
||||
});
|
||||
expect(rootMetadata.alternates).toBeUndefined();
|
||||
expect(rootMetadata.openGraph).toEqual({
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
siteName: SITE_NAME,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("not-found metadata", () => {
|
||||
it("marks the page as noindex", () => {
|
||||
expect(notFoundMetadata.robots).toEqual({
|
||||
index: false,
|
||||
follow: false,
|
||||
});
|
||||
expect(notFoundMetadata.openGraph).toMatchObject({
|
||||
title: "Not Found",
|
||||
description: "The page you're looking for doesn't exist.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const SITE_NAME = "Domainstack";
|
||||
export const SITE_TAGLINE = "Domain Intelligence Made Easy";
|
||||
export const SITE_TITLE = `${SITE_NAME} — ${SITE_TAGLINE}`;
|
||||
export const SITE_DESCRIPTION =
|
||||
"Instant lookups for WHOIS, DNS, hosting, certificates, SEO and more, plus free domain tracking and change alerts.";
|
||||
|
||||
const openGraphDefaults = {
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
siteName: SITE_NAME,
|
||||
} as const satisfies Metadata["openGraph"];
|
||||
|
||||
const twitterDefaults = {
|
||||
card: "summary_large_image",
|
||||
} as const satisfies Metadata["twitter"];
|
||||
|
||||
export type CreateMetadataInput = {
|
||||
/**
|
||||
* Pathname for `rel=canonical` and `og:url`, e.g. `/help` or `/github.com`.
|
||||
* Resolved against `metadataBase` from the root layout.
|
||||
*/
|
||||
path?: string;
|
||||
} & Omit<Metadata, "metadataBase">;
|
||||
|
||||
function socialTitle(title: Metadata["title"]): string | undefined {
|
||||
if (typeof title === "string") return title;
|
||||
if (!title || typeof title !== "object") return undefined;
|
||||
if ("absolute" in title && title.absolute) return title.absolute;
|
||||
if ("default" in title && title.default) return title.default;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds page metadata with site-wide Open Graph / Twitter defaults applied.
|
||||
*
|
||||
* `title` and `description` are copied onto `og:*` and `twitter:*` unless the
|
||||
* page already set those fields. Next.js replaces parent `openGraph` and
|
||||
* `alternates` objects entirely when a child sets them, so every page should
|
||||
* go through this helper instead of relying on layout inheritance.
|
||||
*/
|
||||
export function createMetadata({
|
||||
path,
|
||||
title,
|
||||
description,
|
||||
openGraph,
|
||||
twitter,
|
||||
alternates,
|
||||
...rest
|
||||
}: CreateMetadataInput): Metadata {
|
||||
const canonical = alternates?.canonical ?? path;
|
||||
const ogTitle = socialTitle(title);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
...(title !== undefined ? { title } : {}),
|
||||
...(description !== undefined ? { description } : {}),
|
||||
...(canonical || alternates
|
||||
? {
|
||||
alternates: {
|
||||
...alternates,
|
||||
...(canonical ? { canonical } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
openGraph: {
|
||||
...openGraphDefaults,
|
||||
...(path ? { url: path } : {}),
|
||||
...(ogTitle ? { title: ogTitle } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...openGraph,
|
||||
},
|
||||
twitter: {
|
||||
...twitterDefaults,
|
||||
...(ogTitle ? { title: ogTitle } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...twitter,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const notFoundMetadata = createMetadata({
|
||||
title: "Not Found",
|
||||
description: "The page you're looking for doesn't exist.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const rootMetadata: Metadata = {
|
||||
applicationName: SITE_NAME,
|
||||
title: {
|
||||
default: SITE_TITLE,
|
||||
template: `%s — ${SITE_NAME}`,
|
||||
},
|
||||
description: SITE_DESCRIPTION,
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"),
|
||||
openGraph: openGraphDefaults,
|
||||
twitter: twitterDefaults,
|
||||
};
|
||||
Reference in New Issue
Block a user