diff --git a/packages/seo/.npmignore b/packages/seo/.npmignore
new file mode 100644
index 000000000000..720552d7469b
--- /dev/null
+++ b/packages/seo/.npmignore
@@ -0,0 +1,11 @@
+# Source Code
+src/
+
+# Test Files
+__tests__/
+
+# Rollup Config files
+rollup.config.js
+
+# Typescript Config files
+tsconfig.json
diff --git a/packages/seo/LICENSE b/packages/seo/LICENSE
new file mode 100644
index 000000000000..70fe745efb62
--- /dev/null
+++ b/packages/seo/LICENSE
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (C) 2020 by MrHope
+Copyright (c) 2020 Loris Leiva
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/packages/seo/package.json b/packages/seo/package.json
new file mode 100644
index 000000000000..0165afa72641
--- /dev/null
+++ b/packages/seo/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@mr-hope/vuepress-plugin-seo",
+ "version": "2.0.0-alpha.0",
+ "description": "SEO plugin for vuepress",
+ "keywords": [
+ "vuepress-plugin",
+ "seo",
+ "search",
+ "share"
+ ],
+ "homepage": "https://vuepress-theme-hope.github.io/seo/",
+ "bugs": {
+ "url": "https://github.com/vuepress-theme-hope/vuepress-theme-hope/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/vuepress-theme-hope/vuepress-theme-hope.git",
+ "directory": "packages/seo"
+ },
+ "license": "MIT",
+ "author": {
+ "name": "Mr.Hope",
+ "email": "zhangbowang1998@gmail.com",
+ "url": "https://mrhope.site"
+ },
+ "main": "node/index.js",
+ "types": "node/index.d.ts",
+ "files": [
+ "node"
+ ],
+ "scripts": {
+ "build": "rollup -c",
+ "clean": "rimraf ./node",
+ "dev": "rollup -c -w"
+ },
+ "dependencies": {
+ "@mr-hope/vuepress-shared": "2.0.0-alpha.0",
+ "@types/fs-extra": "^9.0.11",
+ "chalk": "^4.1.1",
+ "fs-extra": "^10.0.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/seo/readme.md b/packages/seo/readme.md
new file mode 100644
index 000000000000..61781ab87b06
--- /dev/null
+++ b/packages/seo/readme.md
@@ -0,0 +1,26 @@
+
+
+
+
+@mr-hope/vuepress-plugin-seo
+VuePress SEO plugin🛠 / VuePress SEO 插件🛠
+
+[![Version](https://img.shields.io/npm/v/@mr-hope/vuepress-plugin-seo.svg?style=flat-square&logo=npm) ![Downloads](https://img.shields.io/npm/dm/@mr-hope/vuepress-plugin-seo.svg?style=flat-square&logo=npm) ![Size](https://img.shields.io/bundlephobia/min/@mr-hope/vuepress-plugin-seo?style=flat-square&logo=npm)](https://www.npmjs.com/package/@mr-hope/vuepress-plugin-seo)
+
+
+
+VuePress SEO plugin🛠 / VuePress SEO 插件 🛠
+
+## [Official Docs](https://vuepress-theme-hope.github.io/seo/) | [官方文档](https://vuepress-theme-hope.github.io/seo/zh/)
+
+## 安装 / Install
+
+```bash
+npm i -D @mr-hope/vuepress-plugin-seo
+```
+
+Or
+
+```bash
+yarn add -D @mr-hope/vuepress-plugin-seo
+```
diff --git a/packages/seo/rollup.config.js b/packages/seo/rollup.config.js
new file mode 100644
index 000000000000..0de61c30bf9a
--- /dev/null
+++ b/packages/seo/rollup.config.js
@@ -0,0 +1,13 @@
+import { rollupTypescript } from "../../script/rollup";
+
+export default rollupTypescript("node/index", {
+ external: [
+ "@mr-hope/vuepress-shared",
+ "@vuepress/core",
+ "@vuepress/client",
+ "chalk",
+ "fs-extra",
+ "path",
+ "sitemap",
+ ],
+});
diff --git a/packages/seo/src/node/index.ts b/packages/seo/src/node/index.ts
new file mode 100644
index 000000000000..fd41e9a2f0d3
--- /dev/null
+++ b/packages/seo/src/node/index.ts
@@ -0,0 +1,54 @@
+import { resolvePagePermalink } from "@vuepress/core";
+import { generateRobotsTxt, generateSeo } from "./seo";
+import { appendMeta } from "./meta";
+
+import type { Page, Plugin } from "@vuepress/core";
+import type { PageSeoInfo, SeoContent, SeoOptions } from "./types";
+
+export * from "./types";
+
+export const seoPlugin: Plugin = (options, app) => {
+ const { base, themeConfig } = app.options;
+
+ const seoOption =
+ Object.keys(options).length > 0
+ ? options
+ : (themeConfig.seo as SeoOptions) || {};
+
+ return {
+ name: "seo",
+
+ extendsPageData(page): void {
+ const meta = page.frontmatter.head || [];
+ const pageSeoInfo: PageSeoInfo = {
+ page: page as Page & { lastUpdatedTime?: number } & Record<
+ string,
+ unknown
+ >,
+ app,
+ permalink: resolvePagePermalink(page),
+ };
+ const metaContext: SeoContent = {
+ ...generateSeo(seoOption, base, pageSeoInfo),
+ ...(seoOption.seo ? seoOption.seo(pageSeoInfo) : {}),
+ };
+
+ appendMeta(meta, metaContext, seoOption);
+ if (seoOption.customHead) seoOption.customHead(meta, pageSeoInfo);
+
+ page.frontmatter.meta = meta;
+ },
+
+ async generated(): Promise {
+ await generateRobotsTxt(app.dir);
+ },
+
+ plugins: [
+ ["@mr-hope/last-update", themeConfig.lastUpdate || true],
+
+ ["@vuepress/last-updated", false],
+ ],
+ };
+};
+
+export default seoPlugin;
diff --git a/packages/seo/src/node/meta.ts b/packages/seo/src/node/meta.ts
new file mode 100644
index 000000000000..d56cab08a9a2
--- /dev/null
+++ b/packages/seo/src/node/meta.ts
@@ -0,0 +1,57 @@
+import type { HeadConfig } from "@vuepress/core";
+import type { ArticleSeoContent, SeoContent, SeoOptions } from "./types";
+
+interface MetaOptions {
+ name: string;
+ content: string;
+ attribute?: string;
+}
+
+const addMeta = (
+ meta: HeadConfig[],
+ {
+ name,
+ content,
+ attribute = ["article:", "og:"].some((type) => name.startsWith(type))
+ ? "property"
+ : "name",
+ }: MetaOptions
+): void => {
+ if (content) meta.push(["meta", { [attribute]: name, content }]);
+};
+
+export const appendMeta = (
+ head: HeadConfig[],
+ content: SeoContent,
+ options: SeoOptions
+): void => {
+ for (const property in content)
+ switch (property) {
+ case "article:tag":
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ (content as ArticleSeoContent)["article:tag"]!.forEach((tag: string) =>
+ addMeta(head, { name: "article:tag", content: tag })
+ );
+ break;
+ case "og:locale:alternate":
+ content["og:locale:alternate"].forEach((locale: string) => {
+ if (locale !== content["og:locale"])
+ addMeta(head, { name: "og:locale:alternate", content: locale });
+ });
+ break;
+ default:
+ addMeta(head, {
+ name: property,
+ content: content[property as keyof SeoContent] as string,
+ });
+ }
+
+ if (options.restrictions)
+ addMeta(head, {
+ name: "og:restrictions:age",
+ content: options.restrictions,
+ });
+
+ if (options.twitterID)
+ addMeta(head, { name: "twitter:creator", content: options.twitterID });
+};
diff --git a/packages/seo/src/node/seo.ts b/packages/seo/src/node/seo.ts
new file mode 100644
index 000000000000..b02dc691babb
--- /dev/null
+++ b/packages/seo/src/node/seo.ts
@@ -0,0 +1,112 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import { getDate } from "@mr-hope/vuepress-shared";
+import { black, blue, cyan } from "chalk";
+import { readFile, existsSync, writeFile } from "fs-extra";
+import { getLocales, resolveUrl } from "./utils";
+
+import type { AppDir } from "@vuepress/core";
+
+import type { PageSeoInfo, SeoContent, SeoOptions } from "./types";
+
+export const generateSeo = (
+ options: SeoOptions,
+ base: string,
+ { page, app, permalink }: PageSeoInfo
+): SeoContent => {
+ const {
+ frontmatter: {
+ author: pageAuthor,
+ date,
+ banner,
+ cover,
+ tag,
+ tags = tag as string[],
+ },
+ lastUpdatedTime,
+ } = page;
+ const { siteData } = app;
+ const locales = getLocales(siteData.locales);
+
+ const type = ["article", "category", "tag", "timeline"].some(
+ (folder) =>
+ page.filePathRelative && page.filePathRelative.startsWith(`/${folder}`)
+ )
+ ? "website"
+ : "article";
+
+ const author =
+ pageAuthor === false
+ ? ""
+ : (pageAuthor as string) ||
+ options.author ||
+ (app.options.themeConfig.author as string | undefined) ||
+ "";
+ const modifiedTime =
+ typeof lastUpdatedTime === "number"
+ ? new Date(lastUpdatedTime).toISOString()
+ : "";
+ const articleTags: string[] = Array.isArray(tags)
+ ? tags
+ : typeof tag === "string"
+ ? [tag]
+ : [];
+
+ let publishTime = "";
+
+ if (date instanceof Date) publishTime = new Date(date).toISOString();
+ else if (date) {
+ const dateInfo = getDate(date);
+ if (dateInfo && dateInfo.value) publishTime = dateInfo.value.toISOString();
+ }
+
+ return {
+ "og:url": resolveUrl(base, permalink || page.path),
+ "og:site_name": siteData.title,
+ "og:title": page.title,
+ "og:description": page.frontmatter.description || "",
+ "og:type": type,
+ "og:image": cover
+ ? resolveUrl(base, cover)
+ : banner
+ ? resolveUrl(base, banner)
+ : "",
+ "og:updated_time": modifiedTime,
+ "og:locale": page.lang,
+ "og:locale:alternate": locales,
+
+ "twitter:card": "summary_large_image",
+ "twitter:image:alt": siteData.title,
+
+ "article:author": author,
+ "article:tag": articleTags,
+ "article:published_time": publishTime,
+ "article:modified_time": modifiedTime,
+ };
+};
+
+export const generateRobotsTxt = async (dir: AppDir): Promise => {
+ console.log(blue("SEO:"), black.bgYellow("wait"), "Generating robots.txt...");
+ const publicPath = dir.public("robots.txt");
+
+ let content = existsSync(publicPath)
+ ? await readFile(publicPath, { encoding: "utf8" })
+ : "";
+
+ if (content && !content.includes("User-agent"))
+ console.log(
+ blue("SEO:"),
+ black.bgRed("error"),
+ "robots.txt seems invalid!"
+ );
+ else content += "\nUser-agent:*\nDisallow:\n";
+
+ await writeFile(dir.dest("robots.txt"), content, {
+ flag: "w",
+ });
+
+ console.log(
+ blue("SEO:"),
+ black.bgGreen("Success"),
+ `${cyan("robots.txt")} generated`
+ );
+};
diff --git a/packages/seo/src/node/types/index.ts b/packages/seo/src/node/types/index.ts
new file mode 100644
index 000000000000..574300b2664c
--- /dev/null
+++ b/packages/seo/src/node/types/index.ts
@@ -0,0 +1,57 @@
+import type { BasePageFrontMatter } from "@mr-hope/vuepress-shared";
+import type { App, HeadConfig, Page, PageFrontmatter } from "@vuepress/core";
+import type { SeoContent } from "./seo";
+
+export * from "./seo";
+
+export type ExtendPage> = Page & {
+ frontmatter: PageFrontmatter;
+ lastUpdatedTime?: number;
+} & ExtendObject;
+
+export interface PageSeoInfo> {
+ page: ExtendPage;
+ app: App;
+ /**
+ * Current page link
+ *
+ * prefer permalink
+ */
+ permalink: string | null;
+}
+
+export interface SeoOptions {
+ /**
+ * 默认作者
+ *
+ * default author
+ */
+ author?: string;
+
+ /**
+ * 你的 Twitter 用户名
+ *
+ * your twitter username
+ */
+ twitterID?: string;
+
+ /**
+ * 内容分级情况
+ *
+ * Content restrictions
+ *
+ * The age rating of the content, the format is `[int]+`, such as `'13+'`
+ */
+ restrictions?: string;
+
+ /** Function to generate seo */
+ seo?: >(
+ info: PageSeoInfo
+ ) => Partial;
+
+ /** Function to custom head */
+ customHead?: >(
+ head: HeadConfig[],
+ info: PageSeoInfo
+ ) => void;
+}
diff --git a/packages/seo/src/node/types/seo.ts b/packages/seo/src/node/types/seo.ts
new file mode 100644
index 000000000000..4c8dbf696e90
--- /dev/null
+++ b/packages/seo/src/node/types/seo.ts
@@ -0,0 +1,195 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+export interface BaseSeoContent {
+ /**
+ * 文章的标题,不包含任何品牌,例如你的网站名称。
+ *
+ * The title of your object as it should appear within the graph, e.g., "The Rock".
+ */
+ "og:title": string;
+ /**
+ * 页面类型,根据你选择类别的不同,可能需要填写其他属性。
+ *
+ * The type of your object, e.g., "video.movie".
+ * Depending on the type you specify, other properties may also be required.
+ */
+ "og:type":
+ | "music.song"
+ | "music.album"
+ | "music.playlist"
+ | "music.radio_station"
+ | "video.movie"
+ | "video.episode"
+ | "video.tv_show"
+ | "video.other"
+ | "article"
+ | "book"
+ | "profile"
+ | "website";
+ /**
+ * 页面的图片网址。图片应至少为 600×315 像素,但最好是 1200×630 像素或更大的尺寸 (大小不超过 5MB)。
+ * 将长宽比保持在 1.91:1 左右,以避免裁剪。
+ * 游戏图标应为正方形,且至少为 600×600 像素。
+ * 如果在发布图片后更新了图片,请使用新网址,因为系统会根据之前的网址缓存图片,可能不会更新图片。
+ *
+ * An image URL which should represent your object within the graph.
+ */
+ "og:image": string;
+ /**
+ * 页面的权威链接。此标签应该是未加修饰的网址,没有会话变量、用户识别参数或计数器。
+ * 此网址的“赞”和“分享”将在此网址中汇总。例如,移动域网址应将桌面版网址指定为权威链接,用于跨不同页面版本汇总“赞”和“分享”
+ *
+ * The canonical URL of your object that will be used as its permanent ID in the graph,
+ * e.g., "http://www.imdb.com/title/tt0117500/".
+ */
+ "og:url": string;
+}
+
+export interface SimpleSeoContent extends BaseSeoContent {
+ /**
+ * 可选的音频文件
+ *
+ * A URL to an audio file to accompany this object.
+ */
+ "og:audio"?: string;
+ /**
+ * 可选的视频文件
+ *
+ * A URL to a video file that complements this object.
+ */
+ "og:video"?: string;
+ /**
+ * 内容的简略说明,通常为 2-4 个句子
+ *
+ * A one to two sentence description of your object.
+ */
+ "og:description": string;
+ /**
+ * 当文章出现在句子中时,前面的量词
+ *
+ * The word that appears before this object's title in a sentence.
+ * An enum of (a, an, the, "", auto). If auto is chosen, the consumer of your data should
+ * chose between "a" or "an". Default is "" (blank).
+ */
+ "og:determiner"?: "a" | "an" | "the" | "" | "auto";
+ /**
+ * 页面使用的语言
+ *
+ * The locale these tags are marked up in. Of the format language_TERRITORY. Default is en_US.
+ */
+ "og:locale": string;
+ /**
+ * 页面支持的语言
+ *
+ * An array of other locales this page is available in.
+ */
+ "og:locale:alternate": string[];
+ /**
+ * 网站名称
+ *
+ * If your object is part of a larger web site, the name which should be
+ * displayed for the overall site. e.g., "IMDb".
+ */
+ "og:site_name": string;
+}
+
+export interface ArticleSeoContent extends SimpleSeoContent {
+ /**
+ * 文章发表时间
+ *
+ * When the article was first published.
+ */
+ "article:published_time"?: string;
+ /**
+ * 文章上次修改时间
+ *
+ * When the article was last changed.
+ */
+ "article:modified_time"?: string;
+ /**
+ * 文章过期时间
+ *
+ * When the article is out of date after.
+ */
+ "article:expiration_time"?: string;
+ /**
+ * 文章作者
+ *
+ * Writers of the article
+ */
+ "article:author"?: string;
+ /**
+ * 文章章节
+ *
+ * A high-level section name. E.g. Technology
+ */
+ "article:section"?: string;
+ /**
+ * 文章标签
+ *
+ * Tag words associated with this article.
+ */
+ "article:tag"?: string[];
+}
+
+export interface FacebookSeoContent extends SimpleSeoContent {
+ /**
+ * 进行 Facebook 成效分析所使用的应用编号
+ *
+ * App id which Facebook use to analyze
+ */
+ "fb:app_id": string;
+}
+
+export interface TwitterSeoContent extends SimpleSeoContent {
+ /** The card type */
+ "twitter:card"?: "summary" | "summary_large_image" | "app" | "player";
+ /**
+ * 用户的 Twitter ID
+ *
+ * username of website
+ */
+ "twitter:site": string;
+ /**
+ * 创作者用户名
+ *
+ * username of content creator
+ */
+
+ "twitter:creator": string;
+
+ /**
+ * 图片替代文字
+ *
+ * A text description of the image conveying the essential nature of an image
+ * to users who are visually impaired. Maximum 420 characters.
+ */
+ "twitter:image:alt": string;
+}
+
+export interface ExtendedSeoContent extends SimpleSeoContent {
+ /**
+ * 网站更新时间
+ *
+ * page update time
+ */
+ "og:updated_time": string;
+ /**
+ * 是否启用富媒体
+ *
+ * Whether to enable rich attachment
+ */
+ "og:rich_attachment": "true" | "false";
+ /**
+ * 内容年龄限制
+ *
+ * Age Restrictions of the content
+ */
+ "og:restrictions:age": string;
+}
+
+export type SeoContent =
+ | ArticleSeoContent
+ | ExtendedSeoContent
+ | FacebookSeoContent
+ | TwitterSeoContent
+ | SimpleSeoContent;
diff --git a/packages/seo/src/node/utils.ts b/packages/seo/src/node/utils.ts
new file mode 100644
index 000000000000..a87d477b4d70
--- /dev/null
+++ b/packages/seo/src/node/utils.ts
@@ -0,0 +1,13 @@
+import type { SiteLocaleConfig } from "@vuepress/shared";
+
+export const resolveUrl = (base: string, url: string): string =>
+ `${base}${url.replace(/^\//u, "")}`;
+
+export const getLocales = (locales: SiteLocaleConfig = {}): string[] => {
+ const langs: string[] = [];
+
+ for (const path in locales)
+ if (locales[path].lang) langs.push(locales[path].lang as string);
+
+ return langs;
+};
diff --git a/packages/seo/tsconfig.json b/packages/seo/tsconfig.json
new file mode 100644
index 000000000000..523d0f9ae8b9
--- /dev/null
+++ b/packages/seo/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "ESNext",
+ "rootDir": "./src"
+ },
+ "include": ["./src"]
+}