SEO: add JSON-LD structured data
- Add generic JSON-LD injector component and builders - components/seo/JsonLd.tsx - lib/seo/jsonld.ts - Inject Organization and WebSite JSON-LD globally in [lang]/layout - Inject WebApplication JSON-LD on the localized home page - Localize description/url/inLanguage and set alternateName ["PrivyDrop", "PrivyDrop APP"] - Inject FAQPage JSON-LD only on /[lang]/faq (not on home) - Build Q&A from messages.text.faqs - Inject BlogPosting + BreadcrumbList on blog post pages - Use frontmatter.cover as image, localized breadcrumbs Notes - Use stable @id anchors (/#organization, /#website, /[lang]#app, /[lang]/blog/[slug]#post) - Respect multi-language setup across en/zh/ja/es/de/fr/ko - SameAs limited to GitHub and X as provided - Site URL resolved via NEXT_PUBLIC_SITE_URL or defaults to https://www.privydrop.app
This commit is contained in:
@@ -6,6 +6,14 @@ import { mdxOptions } from "@/lib/mdx-config";
|
||||
import { mdxComponents } from "@/components/blog/MDXComponents";
|
||||
import { TableOfContents } from "@/components/blog/TableOfContents";
|
||||
import { generateMetadata } from "./metadata";
|
||||
import JsonLd from "@/components/seo/JsonLd";
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildBlogPostingJsonLd,
|
||||
buildBreadcrumbJsonLd,
|
||||
getSiteUrl,
|
||||
} from "@/lib/seo/jsonld";
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
|
||||
export { generateMetadata };
|
||||
|
||||
@@ -15,13 +23,37 @@ export default async function BlogPost({
|
||||
params: { slug: string; lang: string };
|
||||
}) {
|
||||
const post = await getPostBySlug(params.slug, params.lang);
|
||||
const messages = await getDictionary(params.lang);
|
||||
|
||||
if (!post) {
|
||||
return <div>Post not found</div>;
|
||||
}
|
||||
|
||||
const siteUrl = getSiteUrl();
|
||||
const postUrl = `${siteUrl}/${params.lang}/blog/${params.slug}`;
|
||||
const imageUrl = absoluteUrl(post.frontmatter.cover, siteUrl);
|
||||
const postLd = buildBlogPostingJsonLd({
|
||||
siteUrl,
|
||||
url: postUrl,
|
||||
title: post.frontmatter.title,
|
||||
description: post.frontmatter.description,
|
||||
datePublished: post.frontmatter.date,
|
||||
dateModified: post.frontmatter.date,
|
||||
authorName: post.frontmatter.author,
|
||||
imageUrl,
|
||||
inLanguage: params.lang,
|
||||
});
|
||||
const breadcrumbsLd = buildBreadcrumbJsonLd({
|
||||
items: [
|
||||
{ name: messages.text.Header.Home_dis, item: `${siteUrl}/${params.lang}` },
|
||||
{ name: messages.text.Header.Blog_dis, item: `${siteUrl}/${params.lang}/blog` },
|
||||
{ name: post.frontmatter.title, item: postUrl },
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||
<JsonLd id="post-ld" data={[postLd, breadcrumbsLd]} />
|
||||
{/* Use md: prefix to handle flex layout for medium screens and above */}
|
||||
<div className="block md:flex md:gap-8">
|
||||
{/* Article content area */}
|
||||
|
||||
@@ -2,6 +2,8 @@ import FAQSection from "@/components/web/FAQSection";
|
||||
import type { Metadata } from "next";
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
import { supportedLocales } from "@/constants/i18n-config";
|
||||
import JsonLd from "@/components/seo/JsonLd";
|
||||
import { buildFaqJsonLd } from "@/lib/seo/jsonld";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -38,5 +40,25 @@ export default async function FAQ({
|
||||
params: { lang: string };
|
||||
}) {
|
||||
const messages = await getDictionary(lang);
|
||||
return <FAQSection messages={messages} />;
|
||||
const faqsData = (messages as any).text.faqs as Record<string, string>;
|
||||
const questionKeys = Object.keys(faqsData).filter((k) => k.startsWith("question_"));
|
||||
const faqs = questionKeys
|
||||
.map((qKey) => {
|
||||
const idx = qKey.split("_")[1];
|
||||
const aKey = `answer_${idx}`;
|
||||
const q = faqsData[qKey];
|
||||
const a = faqsData[aKey];
|
||||
if (q && a) return { question: q, answer: a };
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as { question: string; answer: string }[];
|
||||
|
||||
const faqLd = buildFaqJsonLd({ inLanguage: lang, faqs });
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd id="faq-ld" data={faqLd} />
|
||||
<FAQSection messages={messages} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ import Header from "@/components/web/Header";
|
||||
import Footer from "@/components/web/Footer";
|
||||
import { ThemeProvider } from "@/components/web/theme-provider";
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
import JsonLd from "@/components/seo/JsonLd";
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildOrganizationJsonLd,
|
||||
buildWebSiteJsonLd,
|
||||
getSiteUrl,
|
||||
} from "@/lib/seo/jsonld";
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
@@ -12,11 +19,27 @@ export default async function RootLayout({
|
||||
params: { lang: string };
|
||||
}>) {
|
||||
const messages = await getDictionary(lang);
|
||||
const siteUrl = getSiteUrl();
|
||||
const logoUrl = absoluteUrl("/logo.png", siteUrl);
|
||||
const orgJson = buildOrganizationJsonLd({
|
||||
siteUrl,
|
||||
logoUrl,
|
||||
sameAs: [
|
||||
"https://github.com/david-bai00/PrivyDrop",
|
||||
"https://x.com/David_vision66",
|
||||
],
|
||||
});
|
||||
const websiteJson = buildWebSiteJsonLd({
|
||||
siteUrl,
|
||||
name: "PrivyDrop",
|
||||
inLanguage: lang,
|
||||
});
|
||||
|
||||
return (
|
||||
<html lang={lang} className="h-full" suppressHydrationWarning>
|
||||
<head />
|
||||
<body className="min-h-full flex flex-col">
|
||||
<JsonLd id="global-ld" data={[orgJson, websiteJson]} />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
|
||||
@@ -2,6 +2,8 @@ import HomeClient from "./HomeClient";
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
import { Metadata } from "next";
|
||||
import { supportedLocales } from "@/constants/i18n-config";
|
||||
import JsonLd from "@/components/seo/JsonLd";
|
||||
import { buildWebAppJsonLd, getSiteUrl, absoluteUrl } from "@/lib/seo/jsonld";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -39,6 +41,23 @@ export default async function Home({
|
||||
params: { lang: string };
|
||||
}) {
|
||||
const messages = await getDictionary(lang);
|
||||
const siteUrl = getSiteUrl();
|
||||
const webAppLd = buildWebAppJsonLd({
|
||||
siteUrl,
|
||||
path: `/${lang}`,
|
||||
name: "PrivyDrop",
|
||||
alternateName: ["PrivyDrop", "PrivyDrop APP"],
|
||||
description: messages.meta.home.description,
|
||||
inLanguage: lang,
|
||||
imageUrl: absoluteUrl("/logo.png", siteUrl),
|
||||
applicationCategory: "UtilityApplication",
|
||||
operatingSystem: "Web Browser",
|
||||
});
|
||||
|
||||
return <HomeClient messages={messages} lang={lang} />;
|
||||
return (
|
||||
<>
|
||||
<JsonLd id="home-ld" data={webAppLd} />
|
||||
<HomeClient messages={messages} lang={lang} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
|
||||
type JsonLdProps = {
|
||||
data: Record<string, any> | Record<string, any>[];
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export default function JsonLd({ data, id }: JsonLdProps) {
|
||||
const blocks = Array.isArray(data) ? data : [data];
|
||||
return (
|
||||
<>
|
||||
{blocks.map((item, idx) => (
|
||||
<script
|
||||
key={id ? `${id}-${idx}` : idx}
|
||||
type="application/ld+json"
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(item) }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
export const getSiteUrl = (): string => {
|
||||
return process.env.NEXT_PUBLIC_SITE_URL || "https://www.privydrop.app";
|
||||
};
|
||||
|
||||
export const absoluteUrl = (path: string, siteUrl = getSiteUrl()): string => {
|
||||
if (!path) return siteUrl;
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
return `${siteUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
};
|
||||
|
||||
export function buildOrganizationJsonLd(params: {
|
||||
siteUrl?: string;
|
||||
name?: string;
|
||||
logoUrl: string;
|
||||
sameAs?: string[];
|
||||
}) {
|
||||
const siteUrl = params.siteUrl || getSiteUrl();
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"@id": `${siteUrl}/#organization`,
|
||||
name: params.name || "PrivyDrop",
|
||||
url: `${siteUrl}/`,
|
||||
logo: params.logoUrl,
|
||||
sameAs: params.sameAs || [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWebSiteJsonLd(params: {
|
||||
siteUrl?: string;
|
||||
name?: string;
|
||||
inLanguage?: string;
|
||||
}) {
|
||||
const siteUrl = params.siteUrl || getSiteUrl();
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"@id": `${siteUrl}/#website`,
|
||||
url: `${siteUrl}/`,
|
||||
name: params.name || "PrivyDrop",
|
||||
publisher: { "@id": `${siteUrl}/#organization` },
|
||||
inLanguage: params.inLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWebAppJsonLd(params: {
|
||||
siteUrl?: string;
|
||||
path: string; // e.g. '/zh'
|
||||
name: string;
|
||||
description: string;
|
||||
inLanguage?: string;
|
||||
alternateName?: string[];
|
||||
imageUrl?: string;
|
||||
applicationCategory?: string; // default UtilityApplication
|
||||
operatingSystem?: string; // default Web Browser
|
||||
}) {
|
||||
const siteUrl = params.siteUrl || getSiteUrl();
|
||||
const url = absoluteUrl(params.path, siteUrl);
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"@id": `${url}#app`,
|
||||
name: params.name,
|
||||
alternateName: params.alternateName?.length ? params.alternateName : undefined,
|
||||
description: params.description,
|
||||
applicationCategory: params.applicationCategory || "UtilityApplication",
|
||||
operatingSystem: params.operatingSystem || "Web Browser",
|
||||
isAccessibleForFree: true,
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
||||
url,
|
||||
image: params.imageUrl,
|
||||
publisher: { "@id": `${siteUrl}/#organization` },
|
||||
inLanguage: params.inLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFaqJsonLd(params: {
|
||||
inLanguage?: string;
|
||||
faqs: { question: string; answer: string }[];
|
||||
}) {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: params.faqs.map((f) => ({
|
||||
"@type": "Question",
|
||||
name: f.question,
|
||||
acceptedAnswer: { "@type": "Answer", text: f.answer },
|
||||
})),
|
||||
inLanguage: params.inLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBlogPostingJsonLd(params: {
|
||||
siteUrl?: string;
|
||||
url: string; // absolute url
|
||||
title: string;
|
||||
description: string;
|
||||
datePublished: string;
|
||||
dateModified?: string;
|
||||
authorName: string;
|
||||
imageUrl?: string;
|
||||
inLanguage?: string;
|
||||
}) {
|
||||
const siteUrl = params.siteUrl || getSiteUrl();
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
"@id": `${params.url}#post`,
|
||||
headline: params.title,
|
||||
description: params.description,
|
||||
datePublished: params.datePublished,
|
||||
dateModified: params.dateModified || params.datePublished,
|
||||
author: { "@type": "Person", name: params.authorName },
|
||||
publisher: { "@id": `${siteUrl}/#organization` },
|
||||
mainEntityOfPage: params.url,
|
||||
image: params.imageUrl,
|
||||
inLanguage: params.inLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBreadcrumbJsonLd(params: {
|
||||
items: { name: string; item: string }[]; // absolute urls
|
||||
}) {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: params.items.map((it, idx) => ({
|
||||
"@type": "ListItem",
|
||||
position: idx + 1,
|
||||
name: it.name,
|
||||
item: it.item,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user