Leo 3 jaren geleden
bovenliggende
commit
1f482e7544
5 gewijzigde bestanden met toevoegingen van 350 en 272 verwijderingen
  1. 115 0
      components/SeoHead/index.tsx
  2. 15 4
      pages/_app.tsx
  3. 62 130
      pages/novel/[slug]/[chapter].tsx
  4. 77 136
      pages/novel/[slug]/index.tsx
  5. 81 2
      pages/novels/[genre].tsx

+ 115 - 0
components/SeoHead/index.tsx

@@ -0,0 +1,115 @@
+import Head from "next/head";
+import { ReactNode } from "react";
+
+export interface SeoHeadConfig {
+  title?: string;
+  description?: string;
+  keywords?: string;
+  url?: string;
+  siteName?: string;
+  img?: string;
+  canonical?: string;
+  pre?: string;
+  next?: string;
+  jsonLd?: string;
+}
+
+interface SeoHeadProps {
+  seoConfig: SeoHeadConfig;
+  children?: ReactNode;
+}
+
+export function SeoHead({ seoConfig, children }: SeoHeadProps) {
+  return (
+    <Head>
+      {seoConfig.title ? <title>{seoConfig.title}</title> : null}
+
+      {seoConfig.description ? (
+        <meta name="description" content={seoConfig.description} />
+      ) : null}
+
+      {seoConfig.keywords ? (
+        <meta name="keywords" content={seoConfig.keywords} />
+      ) : null}
+
+      {seoConfig.url ? (
+        <meta property="og:url" key="og:url" content={seoConfig.url} />
+      ) : null}
+
+      {seoConfig.siteName ? (
+        <meta
+          property="og:site_name"
+          key="og:site_name"
+          content={seoConfig.siteName}
+        />
+      ) : null}
+
+      {seoConfig.title ? (
+        <meta property="og:title" key="og:title" content={seoConfig.title} />
+      ) : null}
+
+      {seoConfig.description ? (
+        <meta
+          property="og:description"
+          key="og:description"
+          content={seoConfig.description}
+        />
+      ) : null}
+
+      {seoConfig.img ? (
+        <meta property="og:image" key="og:image" content={seoConfig.img} />
+      ) : null}
+
+      {seoConfig.title ? (
+        <meta
+          name="twitter:title"
+          key="twitter:title"
+          content={seoConfig.title}
+        />
+      ) : null}
+
+      {seoConfig.description ? (
+        <meta
+          name="twitter:description"
+          key="twitter:description"
+          content={seoConfig.description}
+        />
+      ) : null}
+
+      {seoConfig.img ? (
+        <meta
+          name="twitter:image"
+          key="twitter:image"
+          content={seoConfig.img}
+        />
+      ) : null}
+
+      {seoConfig.canonical ? (
+        <link rel="canonical" key="canonical" href={seoConfig.canonical} />
+      ) : null}
+
+      {seoConfig.pre ? (
+        <link
+          rel="prev"
+          href={`https://${siteConfig.host}/novel${seoConfig.pre}`}
+        />
+      ) : null}
+
+      {seoConfig.next ? (
+        <link
+          rel="next"
+          href={`https://${siteConfig.host}/novel${seoConfig.next}`}
+        />
+      ) : null}
+
+      {seoConfig.jsonLd ? (
+        <script
+          type="application/ld+json"
+          key="application/ld+json"
+          dangerouslySetInnerHTML={{ __html: seoConfig.jsonLd }}
+        />
+      ) : null}
+      {children}
+    </Head>
+  );
+}

+ 15 - 4
pages/_app.tsx

@@ -1,11 +1,10 @@
 import { SWRConfig } from "swr";
-import Head from "next/head";
 import Script from "next/script";
 import type { NextPage } from "next";
 import { useRouter } from "next/router";
 import type { AppProps } from "next/app";
 import App, { AppContext } from "next/app";
-import { ReactElement, ReactNode, useEffect } from "react";
+import { ReactElement, ReactNode, useEffect, useMemo } from "react";
 
 import { get } from "libs/http";
 import { pageview } from "libs/gtag";
@@ -15,6 +14,7 @@ import Layout from "components/common/Layout";
 import getSiteConfig, { SiteConfig } from "libs/getSiteConfig";
 
 import "styles/globals.scss";
+import { SeoHead, SeoHeadConfig } from "components/SeoHead";
 
 export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
   getLayout?: (page: ReactElement) => ReactNode;
@@ -35,6 +35,17 @@ const MyApp = ({ Component, pageProps }: AppPropsWithLayout) => {
   const { fallback, genre, siteConfig, ...otherProps } = pageProps;
   const router = useRouter();
 
+  const seoConfig: SeoHeadConfig = useMemo(() => {
+    return {
+      title: siteConfig.title,
+      description: siteConfig.description,
+      keywords: siteConfig.keywords,
+      url: `https://${siteConfig.host}`,
+      canonical: `https://${siteConfig.host}`,
+      jsonLd: JSON.stringify(siteConfig.jsonLd),
+    };
+  }, [siteConfig.description, siteConfig.host, siteConfig.jsonLd, siteConfig.keywords, siteConfig.title]);
+
   useEffect(() => {
     const handleRouteChange = (url: string) => {
       pageview(url);
@@ -49,7 +60,7 @@ const MyApp = ({ Component, pageProps }: AppPropsWithLayout) => {
 
   return (
     <>
-      <Head>
+      <SeoHead seoConfig={seoConfig}>
         <meta charSet="utf-8" />
         <meta httpEquiv="X-UA-Compatible" content="IE=edge" />
         <meta
@@ -144,7 +155,7 @@ const MyApp = ({ Component, pageProps }: AppPropsWithLayout) => {
             __html: JSON.stringify(siteConfig.jsonLd),
           }}
         />
-      </Head>
+      </SeoHead>
       <Script
         strategy="afterInteractive"
         src={`https://www.googletagmanager.com/gtag/js?id=${GA_TRACKING_ID}`}

+ 62 - 130
pages/novel/[slug]/[chapter].tsx

@@ -18,6 +18,7 @@ import Settings from "components/novel/Settings";
 import type { NextPageWithLayout } from "pages/_app";
 
 import styles from "styles/chapter.module.scss";
+import { SeoHead, SeoHeadConfig } from "components/SeoHead";
 
 const Chapter: NextPageWithLayout<NovelPageProps> = (props) => {
   const { statusCode } = props;
@@ -34,67 +35,8 @@ const Chapter: NextPageWithLayout<NovelPageProps> = (props) => {
 
   const [fontSize, setFontSize] = useState(16);
 
-  const ldJson = useMemo(() => {
-    if (!chapterData) return "";
-
-    return JSON.stringify([
-      {
-        "@context": "https://schema.org",
-        "@type": "Article",
-        url: `https://${siteConfig.host}/novel/${query.slug}/${query.chapter}`,
-        headline: `${chapterData?.title || ""} - ${chapterData?.chapter || ""}`,
-        // TODO: 更新时间
-        datePublished: "2018-03-16T02:13:00.000Z",
-        publisher: {
-          "@type": "Organization",
-          name: siteConfig.siteName,
-          url: `https://${siteConfig.host}/`,
-        },
-        isAccessibleForFree: true,
-        hasPart: {
-          "@type": "WebPageElement",
-          isAccessibleForFree: true,
-          cssSelector: styles["novel"],
-        },
-        // TODO: 作者
-        author: { "@type": "Person", name: "作者" },
-      },
-      {
-        "@context": "https://schema.org",
-        "@type": "BreadcrumbList",
-        itemListElement: [
-          {
-            "@type": "ListItem",
-            position: 1,
-            name: "Home",
-            item: `https://${siteConfig.host}`,
-          },
-          {
-            "@type": "ListItem",
-            position: 2,
-            name: `${chapterData?.title || ""}`,
-            item: `https://${siteConfig.host}/novel/${query.slug}`,
-          },
-          {
-            "@type": "ListItem",
-            position: 3,
-            name: `${chapterData?.title || ""} - ${chapterData?.chapter || ""}`,
-            item: `https://${siteConfig.host}/novel/${query.slug}/${query.chapter}`,
-          },
-        ],
-      },
-      ...siteConfig.jsonLd,
-    ]);
-  }, [
-    chapterData,
-    siteConfig.host,
-    siteConfig.siteName,
-    siteConfig.jsonLd,
-    query.slug,
-    query.chapter,
-  ]);
-
-  const seoConfig = useMemo(() => {
+  const seoConfig: SeoHeadConfig = useMemo(() => {
+    const url = `https://${siteConfig.host}/novel/${query.slug}/${query.chapter}`;
     return {
       title: `${chapterData?.title} - ${chapterData?.chapter} - ${siteConfig.siteName}`,
       // TODO: 作者
@@ -109,17 +51,72 @@ const Chapter: NextPageWithLayout<NovelPageProps> = (props) => {
       ]
         .filter((item) => item)
         .join(", ")}`,
-      url: `https://${siteConfig.host}/novel/${query.slug}/${query.chapter}`,
+      url,
+      canonical: url,
       siteName: siteConfig.siteName,
       // TODO: 图片
       img: "",
+      jsonLd: chapterData
+        ? JSON.stringify([
+            {
+              "@context": "https://schema.org",
+              "@type": "Article",
+              url: `https://${siteConfig.host}/novel/${query.slug}/${query.chapter}`,
+              headline: `${chapterData?.title || ""} - ${
+                chapterData?.chapter || ""
+              }`,
+              // TODO: 更新时间
+              datePublished: "2018-03-16T02:13:00.000Z",
+              publisher: {
+                "@type": "Organization",
+                name: siteConfig.siteName,
+                url: `https://${siteConfig.host}/`,
+              },
+              isAccessibleForFree: true,
+              hasPart: {
+                "@type": "WebPageElement",
+                isAccessibleForFree: true,
+                cssSelector: styles["novel"],
+              },
+              // TODO: 作者
+              author: { "@type": "Person", name: "作者" },
+            },
+            {
+              "@context": "https://schema.org",
+              "@type": "BreadcrumbList",
+              itemListElement: [
+                {
+                  "@type": "ListItem",
+                  position: 1,
+                  name: "Home",
+                  item: `https://${siteConfig.host}`,
+                },
+                {
+                  "@type": "ListItem",
+                  position: 2,
+                  name: `${chapterData?.title || ""}`,
+                  item: `https://${siteConfig.host}/novel/${query.slug}`,
+                },
+                {
+                  "@type": "ListItem",
+                  position: 3,
+                  name: `${chapterData?.title || ""} - ${
+                    chapterData?.chapter || ""
+                  }`,
+                  item: `https://${siteConfig.host}/novel/${query.slug}/${query.chapter}`,
+                },
+              ],
+            },
+            ...siteConfig.jsonLd,
+          ])
+        : "",
     };
   }, [
-    chapterData?.chapter,
-    chapterData?.title,
+    chapterData,
     query.chapter,
     query.slug,
     siteConfig.host,
+    siteConfig.jsonLd,
     siteConfig.keywords,
     siteConfig.siteName,
   ]);
@@ -179,72 +176,7 @@ const Chapter: NextPageWithLayout<NovelPageProps> = (props) => {
 
   return (
     <>
-      <Head>
-        <title>{seoConfig.title}</title>
-        {seoConfig.description ? (
-          <meta name="description" content={seoConfig.description} />
-        ) : null}
-        {seoConfig.keywords ? (
-          <meta name="keywords" content={seoConfig.keywords} />
-        ) : null}
-        <meta property="og:url" key="og:url" content={seoConfig.url} />
-        <meta
-          property="og:site_name"
-          key="og:site_name"
-          content={seoConfig.siteName}
-        />
-        <meta property="og:title" key="og:title" content={seoConfig.title} />
-        {seoConfig.description ? (
-          <meta
-            property="og:description"
-            key="og:description"
-            content={seoConfig.description}
-          />
-        ) : null}
-        {seoConfig.img ? (
-          <meta property="og:image" key="og:image" content={seoConfig.img} />
-        ) : null}
-        <meta
-          name="twitter:title"
-          key="twitter:title"
-          content={seoConfig.title}
-        />
-        {seoConfig.description ? (
-          <meta
-            name="twitter:description"
-            key="twitter:description"
-            content={seoConfig.description}
-          />
-        ) : null}
-        <meta name="twitter:card" key="twitter:card" content="summary" />
-        {seoConfig.img ? (
-          <meta
-            name="twitter:image"
-            key="twitter:image"
-            content={seoConfig.img}
-          />
-        ) : null}
-        <link rel="canonical" key="canonical" href={seoConfig.url} />
-        {chapterData.pre ? (
-          <link
-            rel="prev"
-            href={`https://${siteConfig.host}/novel${chapterData.pre}`}
-          />
-        ) : null}
-        {chapterData.next ? (
-          <link
-            rel="next"
-            href={`https://${siteConfig.host}/novel${chapterData.next}`}
-          />
-        ) : null}
-        {ldJson ? (
-          <script
-            type="application/ld+json"
-            key="application/ld+json"
-            dangerouslySetInnerHTML={{ __html: ldJson }}
-          />
-        ) : null}
-      </Head>
+      <SeoHead seoConfig={seoConfig} />
       <header
         className={clsx("header", styles["chapter-header"], {
           [styles["open"]]: open,

+ 77 - 136
pages/novel/[slug]/index.tsx

@@ -16,6 +16,7 @@ import useStore from "libs/hooks/useStore";
 import NovelCover from "components/NovelCover";
 
 import styles from "styles/novel-info.module.scss";
+import { SeoHead, SeoHeadConfig } from "components/SeoHead";
 
 interface NovelPageProps {
   detail?: Detail;
@@ -36,92 +37,7 @@ const Novel: NextPage<NovelPageProps> = (props) => {
     `/api/novel/${query.slug}/chapters`
   );
 
-  const ldJson = useMemo(() => {
-    return JSON.stringify([
-      {
-        "@context": "https://schema.org",
-        "@type": "Book",
-        mainEntityOfPage: `https://${siteConfig.host}/novel/${query.slug}`,
-        headline: detail?.name,
-        name: detail?.name,
-        genre: detail?.genres[0].name,
-        image: {
-          "@type": "ImageObject",
-          url: detail?.img,
-        },
-        bookFormat: "https://schema.org/EBook",
-        datePublished: detail?.create_time,
-        dateModified: detail?.update_time,
-        author: {
-          "@type": "Person",
-          name: detail?.author,
-        },
-        copyrightHolder: detail?.author,
-        publisher: {
-          "@type": "Organization",
-          name: siteConfig.siteName,
-          logo: {
-            "@type": "ImageObject",
-            url: `https://${siteConfig.host}/favicon-32x32.png`,
-          },
-        },
-        description: detail?.desc,
-        // aggregateRating: {
-        //   "@type": "AggregateRating",
-        //   bestRating: "5.0",
-        //   ratingValue: "4.62",
-        //   ratingCount: "159",
-        // },
-        potentialAction: {
-          "@type": "ReadAction",
-          target: {
-            "@type": "EntryPoint",
-            urlTemplate: `https://${siteConfig.host}/novel/${chapters?.chapters[0].uri}`,
-          },
-        },
-      },
-      {
-        "@context": "https://schema.org",
-        "@type": "BreadcrumbList",
-        itemListElement: [
-          {
-            "@type": "ListItem",
-            position: 1,
-            name: "Home",
-            item: `https://${siteConfig.host}`,
-          },
-          {
-            "@type": "ListItem",
-            position: 2,
-            name: detail?.genres[0].name,
-            item: `https://${siteConfig.host}/novels/${detail?.genres[0].uri}`,
-          },
-          {
-            "@type": "ListItem",
-            position: 3,
-            name: `${detail?.name || ""}`,
-            item: `https://${siteConfig.host}/novel/${query.slug}`,
-          },
-        ],
-      },
-      ...siteConfig.jsonLd,
-    ]);
-  }, [
-    chapters?.chapters,
-    detail?.author,
-    detail?.create_time,
-    detail?.desc,
-    detail?.genres,
-    detail?.img,
-    detail?.name,
-    detail?.update_time,
-    query.slug,
-    siteConfig.host,
-    siteConfig.jsonLd,
-    siteConfig.siteName,
-  ]);
-
-  const seoConfig = useMemo(() => {
+  const seoConfig: SeoHeadConfig = useMemo(() => {
     const keys = detail?.genres.map((item) => item.name).join(", ");
 
     return {
@@ -140,15 +56,89 @@ const Novel: NextPage<NovelPageProps> = (props) => {
       url: `https://${siteConfig.host}/novel/${query.slug}`,
       siteName: siteConfig.siteName,
       img: detail?.img,
+      jsonLd: JSON.stringify([
+        {
+          "@context": "https://schema.org",
+          "@type": "Book",
+          mainEntityOfPage: `https://${siteConfig.host}/novel/${query.slug}`,
+          headline: detail?.name,
+          name: detail?.name,
+          genre: detail?.genres[0].name,
+          image: {
+            "@type": "ImageObject",
+            url: detail?.img,
+          },
+          bookFormat: "https://schema.org/EBook",
+          datePublished: detail?.create_time,
+          dateModified: detail?.update_time,
+          author: {
+            "@type": "Person",
+            name: detail?.author,
+          },
+          copyrightHolder: detail?.author,
+          publisher: {
+            "@type": "Organization",
+            name: siteConfig.siteName,
+            logo: {
+              "@type": "ImageObject",
+              url: `https://${siteConfig.host}/favicon-32x32.png`,
+            },
+          },
+          description: detail?.desc,
+          // aggregateRating: {
+          //   "@type": "AggregateRating",
+          //   bestRating: "5.0",
+          //   ratingValue: "4.62",
+          //   ratingCount: "159",
+          // },
+          potentialAction: {
+            "@type": "ReadAction",
+            target: {
+              "@type": "EntryPoint",
+              urlTemplate: `https://${siteConfig.host}/novel/${chapters?.chapters[0].uri}`,
+            },
+          },
+        },
+        {
+          "@context": "https://schema.org",
+          "@type": "BreadcrumbList",
+          itemListElement: [
+            {
+              "@type": "ListItem",
+              position: 1,
+              name: "Home",
+              item: `https://${siteConfig.host}`,
+            },
+            {
+              "@type": "ListItem",
+              position: 2,
+              name: detail?.genres[0].name,
+              item: `https://${siteConfig.host}/novels/${detail?.genres[0].uri}`,
+            },
+            {
+              "@type": "ListItem",
+              position: 3,
+              name: `${detail?.name || ""}`,
+              item: `https://${siteConfig.host}/novel/${query.slug}`,
+            },
+          ],
+        },
+        ...siteConfig.jsonLd,
+      ]),
     };
   }, [
+    chapters?.chapters,
     detail?.author,
+    detail?.create_time,
+    detail?.desc,
     detail?.genres,
     detail?.img,
     detail?.name,
     detail?.other_name,
+    detail?.update_time,
     query.slug,
     siteConfig.host,
+    siteConfig.jsonLd,
     siteConfig.keywords,
     siteConfig.siteName,
   ]);
@@ -178,56 +168,7 @@ const Novel: NextPage<NovelPageProps> = (props) => {
   }
   return (
     <main>
-      <Head>
-        <title>{seoConfig.title}</title>
-        {seoConfig.description ? (
-          <meta name="description" content={seoConfig.description} />
-        ) : null}
-        {seoConfig.keywords ? (
-          <meta name="keywords" content={seoConfig.keywords} />
-        ) : null}
-        <meta property="og:url" key="og:url" content={seoConfig.url} />
-        <meta
-          property="og:site_name"
-          key="og:site_name"
-          content={seoConfig.siteName}
-        />
-        <meta property="og:title" key="og:title" content={seoConfig.title} />
-        {seoConfig.description ? (
-          <meta property="og:description" key="og:description" content="" />
-        ) : null}
-        {seoConfig.img ? (
-          <meta property="og:image" key="og:image" content={seoConfig.img} />
-        ) : null}
-        <meta
-          name="twitter:title"
-          key="twitter:title"
-          content={seoConfig.title}
-        />
-        {seoConfig.description ? (
-          <meta
-            name="twitter:description"
-            key="twitter:description"
-            content=""
-          />
-        ) : null}
-        <meta name="twitter:card" key="twitter:card" content="summary" />
-        {seoConfig.img ? (
-          <meta
-            name="twitter:image"
-            key="twitter:image"
-            content={seoConfig.img}
-          />
-        ) : null}
-        <link rel="canonical" key="canonical" href={seoConfig.url} />
-        {ldJson ? (
-          <script
-            type="application/ld+json"
-            key="application/ld+json"
-            dangerouslySetInnerHTML={{ __html: ldJson }}
-          />
-        ) : null}
-      </Head>
+      <SeoHead seoConfig={seoConfig} />
       <div
         className={styles["novel-wrap"]}
         style={{

+ 81 - 2
pages/novels/[genre].tsx

@@ -1,6 +1,6 @@
 import clsx from "clsx";
 import Link from "next/link";
-import { useContext } from "react";
+import { useContext, useMemo } from "react";
 import { useRouter } from "next/router";
 import { GetServerSideProps } from "next";
 
@@ -10,16 +10,95 @@ import { Context } from "libs/context";
 import NovelItem from "components/NovelItem";
 
 import styles from "styles/genre.module.scss";
+import useStore from "libs/hooks/useStore";
+import Head from "next/head";
+import { SeoHead, SeoHeadConfig } from "components/SeoHead";
 
 const Genre = () => {
   const { query } = useRouter();
+  const { siteConfig } = useStore();
   const { data } = useGet<ListItem[]>(
     query.genre ? `/api/genre/${query.genre}` : "/api/list"
   );
   const store = useContext(Context);
+  const currentName = useMemo(() => {
+    const item = store.genre.find((item) => item.uri === query.genre);
+    return item ? item.name : "";
+  }, [query.genre, store.genre]);
+
+  const seoConfig: SeoHeadConfig = useMemo(() => {
+    return {
+      title: `${currentName} Novels - ${siteConfig.siteName}`,
+      description: ``,
+      keywords: `${[
+        `${currentName || "All"} stories`,
+        `${currentName || "All"} novels`,
+        `read ${currentName || "All"} novels`,
+        siteConfig.keywords,
+        siteConfig.siteName,
+      ].join(", ")}`,
+      url: `https://${siteConfig.host}/novels${
+        query.genre ? `/${query.genre}` : ""
+      }`,
+      jsonLd: JSON.stringify([
+        {
+          "@context": "https://schema.org",
+          "@type": "BreadcrumbList",
+          itemListElement: [
+            {
+              "@type": "ListItem",
+              position: 1,
+              name: "Home",
+              item: `https://${siteConfig.host}`,
+            },
+            {
+              "@type": "ListItem",
+              position: 2,
+              name: "Novels",
+              item: `https://${siteConfig.host}/novels`,
+            },
+            ...(query.genre
+              ? [
+                  {
+                    "@type": "ListItem",
+                    position: 3,
+                    name: `${currentName} Novels`,
+                    item: `https://${siteConfig.host}/novels/${query.genre}`,
+                  },
+                ]
+              : []),
+          ],
+        },
+        {
+          "@context": "https://schema.org",
+          "@type": "ItemList",
+          itemListElement: (data?.data || []).map((item, idx) => ({
+            "@type": "ListItem",
+            position: idx + 1,
+            url: `https://${siteConfig.host}/novel/${item.uri}`,
+            name: item.name,
+            image:
+              "http://img.webnovel.com/bookcover/16709365405930105/600/600.jpg",
+            author: { "@type": "Person", name: item.author },
+            publisher: { "@type": "Organization", name: siteConfig.siteName },
+          })),
+        },
+        ...siteConfig.jsonLd,
+      ]),
+    };
+  }, [
+    currentName,
+    data?.data,
+    query.genre,
+    siteConfig.host,
+    siteConfig.jsonLd,
+    siteConfig.keywords,
+    siteConfig.siteName,
+  ]);
 
   return (
     <main className="container">
+      <SeoHead seoConfig={seoConfig} />
       <h2 className="novel-title">Genres</h2>
       <div className={styles.genres}>
         <Link
@@ -44,7 +123,7 @@ const Genre = () => {
           </Link>
         ))}
       </div>
-      <h2 className="novel-title">List</h2>
+      <h2 className="novel-title">{`${currentName} Novels`}</h2>
       <ul className="novel-list">
         {(data?.data || []).map((item) => (
           <NovelItem