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"] +}