Feat: Added hreflang link for better SEO. (#1167)

* Update Language type to Readonly

* Add withHrefLang utility function for generating hreflang metadata

* feat: Support for specified language through locale search parameter. (...?locale=zh)

* feat: Added hreflang link for better search engine optimization.
This commit is contained in:
Caspian Zhao 2023-12-20 07:43:36 +00:00 committed by GitHub
parent 6e327f04fd
commit 55bb1229aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 155 additions and 113 deletions

View File

@ -1,11 +1,10 @@
import { Metadata } from "next"
import { HomeFeed } from "~/components/home/HomeFeed"
import { APP_NAME } from "~/lib/env"
import { withHrefLang } from "~/lib/with-hreflang"
export const metadata: Metadata = {
export const generateMetadata = withHrefLang(async () => ({
title: `Comments Activities - ${APP_NAME}`,
}
}))
export default async function FollowingActivities() {
return <HomeFeed type="comments" />

View File

@ -1,11 +1,10 @@
import { Metadata } from "next"
import { HomeFeed } from "~/components/home/HomeFeed"
import { APP_NAME } from "~/lib/env"
import { withHrefLang } from "~/lib/with-hreflang"
export const metadata: Metadata = {
export const generateMetadata = withHrefLang(async () => ({
title: `Following Activities - ${APP_NAME}`,
}
}))
export default async function FollowingActivities() {
return <HomeFeed type="following" />

View File

@ -1,4 +1,3 @@
import { Metadata } from "next"
import { getLocale } from "next-intl/server"
import { dehydrate, Hydrate } from "@tanstack/react-query"
@ -7,11 +6,12 @@ import { HomeFeed } from "~/components/home/HomeFeed"
import { APP_NAME } from "~/lib/env"
import getQueryClient from "~/lib/query-client"
import { Language } from "~/lib/types"
import { withHrefLang } from "~/lib/with-hreflang"
import { prefetchGetFeed } from "~/queries/home.server"
export const metadata: Metadata = {
export const generateMetadata = withHrefLang(async () => ({
title: `Hottest Activities - ${APP_NAME}`,
}
}))
export default async function HottestActivities() {
const queryClient = getQueryClient()

View File

@ -1,4 +1,3 @@
import { Metadata } from "next"
import { getLocale } from "next-intl/server"
import { dehydrate, Hydrate } from "@tanstack/react-query"
@ -7,11 +6,12 @@ import { HomeFeed } from "~/components/home/HomeFeed"
import { APP_NAME } from "~/lib/env"
import getQueryClient from "~/lib/query-client"
import { Language } from "~/lib/types"
import { withHrefLang } from "~/lib/with-hreflang"
import { prefetchGetFeed } from "~/queries/home.server"
export const metadata: Metadata = {
export const generateMetadata = withHrefLang(async () => ({
title: `Latest Activities - ${APP_NAME}`,
}
}))
export default async function LatestActivities() {
const queryClient = getQueryClient()

View File

@ -1,12 +1,11 @@
import { Metadata } from "next"
import { HomeActivitiesTabs } from "~/components/home/HomeActivitiesTabs"
import { HomeSidebar } from "~/components/home/HomeSidebar"
import { APP_NAME } from "~/lib/env"
import { withHrefLang } from "~/lib/with-hreflang"
export const metadata: Metadata = {
export const generateMetadata = withHrefLang(async () => ({
title: `Activities - ${APP_NAME}`,
}
}))
export default async function ActivitiesLayout({
children,

View File

@ -1,4 +1,3 @@
import { Metadata } from "next"
import { getLocale } from "next-intl/server"
import { dehydrate, Hydrate } from "@tanstack/react-query"
@ -8,11 +7,12 @@ import ShortPreviewList from "~/components/site/ShortPreviewList"
import { APP_NAME, APP_SLOGAN } from "~/lib/env"
import getQueryClient from "~/lib/query-client"
import { Language } from "~/lib/types"
import { withHrefLang } from "~/lib/with-hreflang"
import { getPreviewShort, prefetchGetFeed } from "~/queries/home.server"
export const metadata: Metadata = {
export const generateMetadata = withHrefLang(async () => ({
title: `${APP_NAME} - ${APP_SLOGAN}`,
}
}))
export default async function HomeActivities() {
const queryClient = getQueryClient()

View File

@ -1,4 +1,3 @@
import { Metadata } from "next"
import { getLocale } from "next-intl/server"
import { dehydrate, Hydrate } from "@tanstack/react-query"
@ -8,19 +7,12 @@ import { HomeFeed } from "~/components/home/HomeFeed"
import { APP_NAME } from "~/lib/env"
import getQueryClient from "~/lib/query-client"
import { Language } from "~/lib/types"
import { withHrefLang } from "~/lib/with-hreflang"
import { prefetchGetFeed } from "~/queries/home.server"
export function generateMetadata({
searchParams,
}: {
searchParams: {
[key: string]: string | string[] | undefined
}
}): Metadata {
return {
title: `Search: ${searchParams.q} - ${APP_NAME}`,
}
}
export const generateMetadata = withHrefLang(async ({ searchParams }) => ({
title: `Search: ${searchParams?.q} - ${APP_NAME}`,
}))
export default async function Search({
searchParams,

View File

@ -1,4 +1,3 @@
import { Metadata } from "next"
import { getLocale } from "next-intl/server"
import { dehydrate, Hydrate } from "@tanstack/react-query"
@ -7,11 +6,12 @@ import { HomeFeed } from "~/components/home/HomeFeed"
import { APP_NAME } from "~/lib/env"
import getQueryClient from "~/lib/query-client"
import { Language } from "~/lib/types"
import { withHrefLang } from "~/lib/with-hreflang"
import { prefetchGetFeed } from "~/queries/home.server"
export const metadata: Metadata = {
export const generateMetadata = withHrefLang(async () => ({
title: `Shorts - ${APP_NAME}`,
}
}))
export default async function LatestActivities() {
const queryClient = getQueryClient()

View File

@ -1,4 +1,3 @@
import { Metadata } from "next"
import { getLocale } from "next-intl/server"
import { dehydrate, Hydrate } from "@tanstack/react-query"
@ -8,19 +7,16 @@ import ParticipateButton from "~/components/home/ParticipateButton"
import { APP_NAME } from "~/lib/env"
import getQueryClient from "~/lib/query-client"
import { Language } from "~/lib/types"
import { withHrefLang } from "~/lib/with-hreflang"
import { prefetchGetFeed } from "~/queries/home.server"
export function generateMetadata({
params,
}: {
export const generateMetadata = withHrefLang<{
params: {
tag: string
}
}): Metadata {
return {
title: `Tag: ${params.tag} - ${APP_NAME}`,
}
}
}>(async ({ params }) => ({
title: `Tag: ${params.tag} - ${APP_NAME}`,
}))
export default async function Tag({
params,

View File

@ -1,4 +1,3 @@
import { Metadata } from "next"
import { getLocale, getTranslations } from "next-intl/server"
import { dehydrate, Hydrate } from "@tanstack/react-query"
@ -8,22 +7,21 @@ import ParticipateButton from "~/components/home/ParticipateButton"
import { APP_NAME } from "~/lib/env"
import getQueryClient from "~/lib/query-client"
import { Language } from "~/lib/types"
import { withHrefLang } from "~/lib/with-hreflang"
import { prefetchGetFeed } from "~/queries/home.server"
import topics from "../../../../../../../data/topics.json"
export function generateMetadata({
params,
}: {
export const generateMetadata = withHrefLang<{
params: {
topic: string
}
}): Metadata {
}>(async ({ params }) => {
params.topic = decodeURIComponent(params.topic)
return {
title: `Topic: ${params.topic} - ${APP_NAME}`,
}
}
})
export default async function Topic({
params,

View File

@ -1,4 +1,3 @@
import { Metadata } from "next"
import { NextIntlClientProvider } from "next-intl"
import { getMessages, unstable_setRequestLocale } from "next-intl/server"
import { headers } from "next/headers"
@ -29,7 +28,9 @@ import "remark-github-alerts/styles/github-colors-dark-class.css"
import "remark-github-alerts/styles/github-base.css"
import "~/css/main.css"
export const metadata: Metadata = {
import { withHrefLang } from "~/lib/with-hreflang"
export const generateMetadata = withHrefLang(async () => ({
title: `${APP_NAME} - ${APP_SLOGAN}`,
description: APP_DESCRIPTION,
applicationName: APP_NAME,
@ -94,7 +95,7 @@ export const metadata: Metadata = {
site: "@_xLog",
creator: "@_xLog",
},
}
}))
export default async function RootLayout({
children,

View File

@ -1,4 +1,3 @@
import { Metadata } from "next"
import { getLocale, getTranslations } from "next-intl/server"
import { notFound } from "next/navigation"
import serialize from "serialize-javascript"
@ -18,17 +17,16 @@ import { isInRN } from "~/lib/is-in-rn"
import { isOnlyContent } from "~/lib/is-only-content"
import getQueryClient from "~/lib/query-client"
import { Language } from "~/lib/types"
import { withHrefLang } from "~/lib/with-hreflang"
import { fetchGetPage, getSummary } from "~/queries/page.server"
import { fetchGetSite } from "~/queries/site.server"
export async function generateMetadata({
params,
}: {
export const generateMetadata = withHrefLang<{
params: {
site: string
slug: string
}
}): Promise<Metadata> {
}>(async ({ params }) => {
const queryClient = getQueryClient()
const locale = (await getLocale()) as Language
const site = await fetchGetSite(params.site, queryClient)
@ -76,7 +74,7 @@ export async function generateMetadata({
creator: twitterCreator ? `@${twitterCreator}` : undefined,
},
}
}
})
export default async function SitePagePage({
params,

View File

@ -1,20 +1,17 @@
import { Metadata } from "next"
import { dehydrate, Hydrate } from "@tanstack/react-query"
import { SiteArchives } from "~/components/site/SiteArchives"
import getQueryClient from "~/lib/query-client"
import { PageVisibilityEnum } from "~/lib/types"
import { withHrefLang } from "~/lib/with-hreflang"
import { prefetchGetPagesBySite } from "~/queries/page.server"
import { fetchGetSite } from "~/queries/site.server"
export async function generateMetadata({
params,
}: {
export const generateMetadata = withHrefLang<{
params: {
site: string
}
}): Promise<Metadata> {
}>(async ({ params }) => {
const queryClient = getQueryClient()
const site = await fetchGetSite(params.site, queryClient)
@ -24,7 +21,7 @@ export async function generateMetadata({
return {
title,
}
}
})
export default async function SiteArchivesPage({
params,

View File

@ -1,4 +1,3 @@
import { Metadata } from "next"
import { headers } from "next/headers"
import { notFound, redirect } from "next/navigation"
@ -18,6 +17,7 @@ import { isOnlyContent, searchParser } from "~/lib/is-only-content"
import getQueryClient from "~/lib/query-client"
import { ExpandedNote } from "~/lib/types"
import { cn } from "~/lib/utils"
import { withHrefLang } from "~/lib/with-hreflang"
import { fetchGetPage } from "~/queries/page.server"
import {
fetchGetSite,
@ -26,13 +26,11 @@ import {
prefetchGetSiteToSubscriptions,
} from "~/queries/site.server"
export async function generateMetadata({
params,
}: {
export const generateMetadata = withHrefLang<{
params: {
site: string
}
}): Promise<Metadata> {
}>(async ({ params }) => {
const queryClient = getQueryClient()
const site = await fetchGetSite(params.site, queryClient)
@ -80,7 +78,7 @@ export async function generateMetadata({
creator: twitterCreator,
},
}
}
})
export default async function SiteLayout({
children,

View File

@ -1,18 +1,16 @@
import { Metadata } from "next"
import { getTranslations } from "next-intl/server"
import { Image } from "~/components/ui/Image"
import { UniLink } from "~/components/ui/UniLink"
import getQueryClient from "~/lib/query-client"
import { withHrefLang } from "~/lib/with-hreflang"
import { fetchGetSite, getNFTs } from "~/queries/site.server"
export async function generateMetadata({
params,
}: {
export const generateMetadata = withHrefLang<{
params: {
site: string
}
}): Promise<Metadata> {
}>(async ({ params }) => {
const queryClient = getQueryClient()
const site = await fetchGetSite(params.site, queryClient)
@ -22,7 +20,7 @@ export async function generateMetadata({
return {
title,
}
}
})
export default async function SiteNFTPage({
params,

View File

@ -1,20 +1,17 @@
import { Metadata } from "next"
import { dehydrate, Hydrate } from "@tanstack/react-query"
import SiteHome from "~/components/site/SiteHome"
import getQueryClient from "~/lib/query-client"
import { PageVisibilityEnum } from "~/lib/types"
import { withHrefLang } from "~/lib/with-hreflang"
import { prefetchGetPagesBySite } from "~/queries/page.server"
import { fetchGetSite } from "~/queries/site.server"
export async function generateMetadata({
params,
}: {
export const generateMetadata = withHrefLang<{
params: {
site: string
}
}): Promise<Metadata> {
}>(async ({ params }) => {
const queryClient = getQueryClient()
const site = await fetchGetSite(params.site, queryClient)
@ -24,7 +21,7 @@ export async function generateMetadata({
return {
title,
}
}
})
async function SitePortfoliosPage({
params,

View File

@ -1,33 +1,29 @@
import { Metadata } from "next"
import { SearchInput } from "~/components/common/SearchInput"
import { SiteSearch } from "~/components/site/SiteSearch"
import getQueryClient from "~/lib/query-client"
import { withHrefLang } from "~/lib/with-hreflang"
import { fetchGetSite } from "~/queries/site.server"
export async function generateMetadata({
params,
searchParams,
}: {
export const generateMetadata = withHrefLang<{
params: {
site: string
}
searchParams: {
[key: string]: string | string[] | undefined
}
}): Promise<Metadata> {
}>(async ({ params, searchParams }) => {
const queryClient = getQueryClient()
const site = await fetchGetSite(params.site, queryClient)
const site = await fetchGetSite(params?.site, queryClient)
const title = `Search: ${searchParams.q} - ${
const title = `Search: ${searchParams?.q} - ${
site?.metadata?.content?.name || site?.handle
}`
return {
title,
}
}
})
export default async function SiteSearchPage() {
return (

View File

@ -1,20 +1,17 @@
import { Metadata } from "next"
import { dehydrate, Hydrate } from "@tanstack/react-query"
import SiteHome from "~/components/site/SiteHome"
import getQueryClient from "~/lib/query-client"
import { PageVisibilityEnum } from "~/lib/types"
import { withHrefLang } from "~/lib/with-hreflang"
import { prefetchGetPagesBySite } from "~/queries/page.server"
import { fetchGetSite } from "~/queries/site.server"
export async function generateMetadata({
params,
}: {
export const generateMetadata = withHrefLang<{
params: {
site: string
}
}): Promise<Metadata> {
}>(async ({ params }) => {
const queryClient = getQueryClient()
const site = await fetchGetSite(params.site, queryClient)
@ -24,7 +21,7 @@ export async function generateMetadata({
return {
title,
}
}
})
async function SiteShortsPage({
params,

View File

@ -1,21 +1,18 @@
import { Metadata } from "next"
import { dehydrate, Hydrate } from "@tanstack/react-query"
import { SiteArchives } from "~/components/site/SiteArchives"
import getQueryClient from "~/lib/query-client"
import { PageVisibilityEnum } from "~/lib/types"
import { withHrefLang } from "~/lib/with-hreflang"
import { prefetchGetPagesBySite } from "~/queries/page.server"
import { fetchGetSite } from "~/queries/site.server"
export async function generateMetadata({
params,
}: {
export const generateMetadata = withHrefLang<{
params: {
site: string
tag: string
}
}): Promise<Metadata> {
}>(async ({ params }) => {
const queryClient = getQueryClient()
const site = await fetchGetSite(params.site, queryClient)
@ -28,7 +25,7 @@ export async function generateMetadata({
return {
title,
}
}
})
export default async function SiteTagPage({
params,

View File

@ -1,6 +1,8 @@
import { getRequestConfig } from "next-intl/server"
export const locales = ["en", "ja", "zh", "zh-TW"] as const
import { Language } from "./lib/types"
export const locales: Language[] = ["en", "ja", "zh", "zh-TW"]
export const languageNames: Record<(typeof locales)[number], string> = {
en: "English",
ja: "Japanese",

View File

@ -1,6 +1,6 @@
import type { CharacterEntity, NoteEntity } from "crossbell"
export type Language = "en" | "zh" | "zh-TW" | "ja"
export type Language = Readonly<"en" | "zh" | "zh-TW" | "ja">
export type Site = {
id: string

70
src/lib/with-hreflang.ts Normal file
View File

@ -0,0 +1,70 @@
import { Metadata, ResolvingMetadata } from "next"
import { headers } from "next/headers"
import { defaultLocale, locales } from "~/i18n"
type BaseProps = {
params?: Record<string, any>
searchParams?: Record<string, string | string[] | undefined>
}
type GenerateMetadata<T = BaseProps> = (
props: T,
parent: ResolvingMetadata,
) => Promise<Metadata>
export function withHrefLang<T extends BaseProps>(
_generateMetadata: GenerateMetadata<T>,
): GenerateMetadata<T> {
return async function generateMetadata(
props: T,
parent: ResolvingMetadata,
): Promise<Metadata> {
const metadata: Metadata = await _generateMetadata(props, parent)
if (metadata.alternates) {
const { locale } = props?.params || {}
const protocol = headers().get("x-forwarded-proto") || ""
const host = headers().get("x-forwarded-host") || ""
const metadataBase = new URL(`${protocol}://${host}`)
const path = headers().get("x-xlog-pathname") || ""
const redirectedPathName = (locale: string) => {
const search = headers().get("x-xlog-search") || ""
const searchParams = new URLSearchParams(search)
searchParams.set("locale", locale)
return `${path}?${searchParams.toString()}`
}
metadata.metadataBase = metadataBase
/**
* Generate hreflang.
* e.g. In the home page, the hreflang should be:
* <head>
* ...
* <link rel="canonical" href="<URL>?locale=zh"/>
* <link rel="alternate" hrefLang="en" href="<URL>?locale=en"/>
* ...
* <link rel="alternate" hrefLang="x-default" href="<URL>?locale=en"/>
* </head>
* */
metadata.alternates["canonical"] = redirectedPathName(
locale ?? defaultLocale,
)
metadata.alternates["languages"] = {
...locales.reduce(
(acc, locale) => {
const hreflang: string = locale
acc[hreflang] = redirectedPathName(locale)
return acc
},
{} as Record<string, string>,
),
"x-default": redirectedPathName("en"),
}
}
return metadata
}
}

View File

@ -6,6 +6,7 @@ import { getClientIp } from "@supercharge/request-ip"
import { IS_PROD } from "~/lib/constants"
import { defaultLocale, locales } from "./i18n"
import { Language } from "./lib/types"
// HTTPWhiteListPaths: White list of path for plain http request, no HTTPS redirect
const HTTPWhitelistPaths = ["/api/healthcheck"]
@ -17,7 +18,7 @@ export const config = {
}
export async function middleware(req: NextRequest) {
const handleI18nRouting = createMiddleware({
const handleI18nRouting = createMiddleware<Language[]>({
locales,
defaultLocale,
localePrefix: "never",
@ -25,6 +26,13 @@ export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl
const searchParams = new URLSearchParams(req.nextUrl.search)
const specifiedLocale = searchParams.get("locale") as Language | undefined
if (specifiedLocale && locales.includes(specifiedLocale)) {
req.cookies.set("NEXT_LOCALE", specifiedLocale)
}
if (
IS_PROD &&
req.headers.get("x-forwarded-proto") !== "https" &&