From 8a999c58c20006b3a36de52a8502d03344af099d Mon Sep 17 00:00:00 2001 From: "Mr.Hope" Date: Wed, 31 Jan 2024 12:50:59 +0800 Subject: [PATCH] feat(plugin-seo): add seo plugin (#42) --- .vscode/settings.json | 1 + docs/.vuepress/configs/navbar/en.ts | 2 +- docs/.vuepress/configs/navbar/zh.ts | 2 +- docs/.vuepress/configs/sidebar/en.ts | 5 + docs/.vuepress/configs/sidebar/zh.ts | 5 + docs/plugins/seo/README.md | 21 + docs/plugins/seo/config.md | 151 ++++++ docs/plugins/seo/guide.md | 184 +++++++ docs/themes/default/config.md | 10 + docs/zh/plugins/seo/README.md | 21 + docs/zh/plugins/seo/config.md | 151 ++++++ docs/zh/plugins/seo/guide.md | 179 +++++++ docs/zh/themes/default/config.md | 10 + e2e/docs/.vuepress/config.ts | 2 +- e2e/docs/seo/README.md | 31 ++ e2e/tests/plugin-seo/seo.cy.ts | 72 +++ plugins/plugin-seo/package.json | 52 ++ plugins/plugin-seo/src/node/appendHead.ts | 84 +++ plugins/plugin-seo/src/node/appendSEO.ts | 51 ++ .../src/node/generateDescription.ts | 18 + .../plugin-seo/src/node/generateRobotsTxt.ts | 24 + plugins/plugin-seo/src/node/getJSONLDInfo.ts | 55 ++ plugins/plugin-seo/src/node/getOGPInfo.ts | 85 +++ plugins/plugin-seo/src/node/index.ts | 3 + plugins/plugin-seo/src/node/options.ts | 184 +++++++ plugins/plugin-seo/src/node/seoPlugin.ts | 37 ++ .../src/node/utils/getAlternatePaths.ts | 23 + .../plugin-seo/src/node/utils/getAuthor.ts | 31 ++ plugins/plugin-seo/src/node/utils/getCover.ts | 27 + .../plugin-seo/src/node/utils/getImages.ts | 24 + plugins/plugin-seo/src/node/utils/getLinks.ts | 27 + plugins/plugin-seo/src/node/utils/getUrl.ts | 10 + plugins/plugin-seo/src/node/utils/index.ts | 6 + plugins/plugin-seo/src/node/utils/logger.ts | 5 + plugins/plugin-seo/src/typings/frontmatter.ts | 56 ++ plugins/plugin-seo/src/typings/helper.ts | 22 + plugins/plugin-seo/src/typings/index.ts | 4 + plugins/plugin-seo/src/typings/ogp.ts | 195 +++++++ plugins/plugin-seo/src/typings/schema.ts | 91 ++++ .../tests/__fixtures__/src/README.md | 5 + .../tests/__fixtures__/src/description.md | 6 + .../tests/__fixtures__/src/example.md | 114 +++++ .../tests/__fixtures__/src/zh/README.md | 5 + .../tests/__fixtures__/src/zh/description.md | 6 + .../tests/__fixtures__/src/zh/example.md | 123 +++++ .../tests/__fixtures__/theme/empty.ts | 5 + .../__snapshots__/description.spec.ts.snap | 483 ++++++++++++++++++ .../plugin-seo/tests/node/description.spec.ts | 36 ++ plugins/plugin-seo/tsconfig.build.json | 10 + pnpm-lock.yaml | 16 + themes/theme-default/package.json | 1 + themes/theme-default/src/node/defaultTheme.ts | 9 + themes/theme-default/src/shared/options.ts | 9 + themes/theme-default/tsconfig.build.json | 1 + tsconfig.build.json | 1 + 55 files changed, 2788 insertions(+), 3 deletions(-) create mode 100644 docs/plugins/seo/README.md create mode 100644 docs/plugins/seo/config.md create mode 100644 docs/plugins/seo/guide.md create mode 100644 docs/zh/plugins/seo/README.md create mode 100644 docs/zh/plugins/seo/config.md create mode 100644 docs/zh/plugins/seo/guide.md create mode 100644 e2e/docs/seo/README.md create mode 100644 e2e/tests/plugin-seo/seo.cy.ts create mode 100644 plugins/plugin-seo/package.json create mode 100644 plugins/plugin-seo/src/node/appendHead.ts create mode 100644 plugins/plugin-seo/src/node/appendSEO.ts create mode 100644 plugins/plugin-seo/src/node/generateDescription.ts create mode 100644 plugins/plugin-seo/src/node/generateRobotsTxt.ts create mode 100644 plugins/plugin-seo/src/node/getJSONLDInfo.ts create mode 100644 plugins/plugin-seo/src/node/getOGPInfo.ts create mode 100644 plugins/plugin-seo/src/node/index.ts create mode 100644 plugins/plugin-seo/src/node/options.ts create mode 100644 plugins/plugin-seo/src/node/seoPlugin.ts create mode 100644 plugins/plugin-seo/src/node/utils/getAlternatePaths.ts create mode 100644 plugins/plugin-seo/src/node/utils/getAuthor.ts create mode 100644 plugins/plugin-seo/src/node/utils/getCover.ts create mode 100644 plugins/plugin-seo/src/node/utils/getImages.ts create mode 100644 plugins/plugin-seo/src/node/utils/getLinks.ts create mode 100644 plugins/plugin-seo/src/node/utils/getUrl.ts create mode 100644 plugins/plugin-seo/src/node/utils/index.ts create mode 100644 plugins/plugin-seo/src/node/utils/logger.ts create mode 100644 plugins/plugin-seo/src/typings/frontmatter.ts create mode 100644 plugins/plugin-seo/src/typings/helper.ts create mode 100644 plugins/plugin-seo/src/typings/index.ts create mode 100644 plugins/plugin-seo/src/typings/ogp.ts create mode 100644 plugins/plugin-seo/src/typings/schema.ts create mode 100644 plugins/plugin-seo/tests/__fixtures__/src/README.md create mode 100644 plugins/plugin-seo/tests/__fixtures__/src/description.md create mode 100644 plugins/plugin-seo/tests/__fixtures__/src/example.md create mode 100644 plugins/plugin-seo/tests/__fixtures__/src/zh/README.md create mode 100644 plugins/plugin-seo/tests/__fixtures__/src/zh/description.md create mode 100644 plugins/plugin-seo/tests/__fixtures__/src/zh/example.md create mode 100644 plugins/plugin-seo/tests/__fixtures__/theme/empty.ts create mode 100644 plugins/plugin-seo/tests/node/__snapshots__/description.spec.ts.snap create mode 100644 plugins/plugin-seo/tests/node/description.spec.ts create mode 100644 plugins/plugin-seo/tsconfig.build.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 830f9a6ab..b0c8f7388 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,6 +28,7 @@ "frontmatter", "globby", "gtag", + "jsonld", "mdit", "nord", "nprogress", diff --git a/docs/.vuepress/configs/navbar/en.ts b/docs/.vuepress/configs/navbar/en.ts index a653026dc..b2f67c0a5 100644 --- a/docs/.vuepress/configs/navbar/en.ts +++ b/docs/.vuepress/configs/navbar/en.ts @@ -39,7 +39,7 @@ export const navbarEn: NavbarConfig = [ }, { text: 'SEO', - children: ['/plugins/sitemap'], + children: ['/plugins/seo/', '/plugins/sitemap/'], }, { text: 'Syntax Highlighting', diff --git a/docs/.vuepress/configs/navbar/zh.ts b/docs/.vuepress/configs/navbar/zh.ts index a28d6c4dd..7ed2bacc3 100644 --- a/docs/.vuepress/configs/navbar/zh.ts +++ b/docs/.vuepress/configs/navbar/zh.ts @@ -39,7 +39,7 @@ export const navbarZh: NavbarConfig = [ }, { text: '搜索引擎增强', - children: ['/zh/plugins/sitemap'], + children: ['/zh/plugins/seo/', '/zh/plugins/sitemap/'], }, { text: '语法高亮', diff --git a/docs/.vuepress/configs/sidebar/en.ts b/docs/.vuepress/configs/sidebar/en.ts index 8e9e0be1f..a609b2813 100644 --- a/docs/.vuepress/configs/sidebar/en.ts +++ b/docs/.vuepress/configs/sidebar/en.ts @@ -41,6 +41,11 @@ export const sidebarEn: SidebarConfig = { { text: 'SEO', children: [ + { + text: 'SEO', + link: '/plugins/seo/', + children: ['/plugins/seo/guide', '/plugins/seo/config'], + }, { text: 'Sitemap', link: '/plugins/sitemap/', diff --git a/docs/.vuepress/configs/sidebar/zh.ts b/docs/.vuepress/configs/sidebar/zh.ts index 25e742273..39adfa7f9 100644 --- a/docs/.vuepress/configs/sidebar/zh.ts +++ b/docs/.vuepress/configs/sidebar/zh.ts @@ -41,6 +41,11 @@ export const sidebarZh: SidebarConfig = { { text: '搜索引擎增强', children: [ + { + text: '搜索引擎增强', + link: '/zh/plugins/seo/', + children: ['/zh/plugins/seo/guide', '/zh/plugins/seo/config'], + }, { text: '站点地图', link: '/zh/plugins/sitemap/', diff --git a/docs/plugins/seo/README.md b/docs/plugins/seo/README.md new file mode 100644 index 000000000..eef6f1034 --- /dev/null +++ b/docs/plugins/seo/README.md @@ -0,0 +1,21 @@ +# seo + + + +## Usage + +```bash +npm i -D @vuepress/plugin-seo@next +``` + +```ts title=".vuepress/config.ts" +import { seoPlugin } from "@vuepress/plugin-seo"; + +export default { + plugins: [ + seoPlugin({ + // options + }), + ], +} +``` diff --git a/docs/plugins/seo/config.md b/docs/plugins/seo/config.md new file mode 100644 index 000000000..e72c3d70c --- /dev/null +++ b/docs/plugins/seo/config.md @@ -0,0 +1,151 @@ +# Config + +## hostname + +- Type: `string` +- Required: Yes +- Details: + + Deploy hostname. + +## author + +- Type: `Author` + + ```ts + type AuthorName = string; + + interface AuthorInfo { + /** + * Author name + */ + name: string; + + /** + * Author website + */ + url?: string; + + /** + * Author email + */ + email?: string; + } + + type Author = AuthorName | AuthorName[] | AuthorInfo | AuthorInfo[]; + ``` + +- Required: No + +- Details: + + Default author. + +## autoDescription + +- Type: `boolean` +- Default: `true` +- Details: + + Whether generate description automatically + +## canonical + +- Type: `string | ((page: Page) => string | null)` +- Details: + + Canonical link + +## fallBackImage + +- Type: `string` +- Details: + + Fallback Image link when no image are found + +## restrictions + +- Type: `string` +- Details: + + The age rating of the content, the format is `[int]+`, such as `"13+"`. + +## twitterID + +- Type: `string` +- Details: + + Fill in your twitter username. + +## isArticle + +- Type: `(page: Page) => boolean` +- Details: + + Use this option to judge whether the page is an article. + +## ogp + +- Type: + + ```ts + function ogp( + /** OGP info inferred by plugin */ + ogp: SeoContent, + /** Page Object */ + page: Page, + /** VuePress App */ + app: App, + ): SeoContent; + ``` + +- Required: No +- Details: + + Custom OPG Generator. + + You can use this options to edit OGP tags. + +## jsonLd + +- Type: + + ```ts + function jsonLd( + /** JSON-LD Object inferred by plugin */ + jsonLD: ArticleSchema | BlogPostingSchema | WebPageSchema, + /** Page Object */ + page: Page, + /** VuePress App */ + app: App, + ): ArticleSchema | BlogPostingSchema | WebPageSchema; + ``` + +- Required: No + +- Details: + + Custom JSON-LD Generator. + + You can use this options to edit JSON-LD properties. + +## customHead + +- Type: + + ```ts + function customHead( + /** Head tag config */ + head: HeadConfig[], + /** Page Object */ + page: Page, + /** VuePress App */ + app: App, + ): void; + ``` + +- Required: No + +- Details: + + You can use this options to edit tags injected to ``. diff --git a/docs/plugins/seo/guide.md b/docs/plugins/seo/guide.md new file mode 100644 index 000000000..b0a50cbd2 --- /dev/null +++ b/docs/plugins/seo/guide.md @@ -0,0 +1,184 @@ +# Guide + +This plugin will make your site fully support [Open Content Protocol OGP](https://ogp.me/) and [JSON-LD 1.1](https://www.w3.org/TR/json-ld-api/) to enhance the SEO of the site. + + + +## Out of Box + +The plugin works out of the box. Without any config, it will extract information from the page content as much as possible to complete the necessary tags required by OGP and JSON-LD. + +By default, the plugin will read the site config and page frontmatter to automatically generate tags as much as possible. Such as site name, page title, page type, writing date, last update date, and article tags are all automatically generated. + +The following are the `` tags and their values that will be injected into `` by default: + +### Default OGP Generation + +The following are the `` tags and their value injected into `` by default to satisfy OGP: + +| Meta Name | Value | +| :----------------------: | :----------------------------------------------------------------------------------------------------------: | +| `og:url` | `options.hostname` + `path` | +| `og:site_name` | `siteConfig.title` | +| `og:title` | `page.title` | +| `og:description` | `page.frontmatter.description` \|\| auto generated (when `autoDescription` is `true` in plugin options) | +| `og:type` | `"article"` | +| `og:image` | `options.hostname` + `page.frontmatter.image` \|\|first image in page \|\| `fallbackImage` in plugin options | +| `og:updated_time` | `page.git.updatedTime` | +| `og:locale` | `page.lang` | +| `og:locale:alternate` | Other languages in `siteData.locales` | +| `twitter:card` | `"summary_large_image"` (only available when image found) | +| `twitter:image:alt` | `page.title` (only available when image found) | +| `article:author` | `page.frontmatter.author` \|\| `options.author` | +| `article:tag` | `page.frontmatter.tags` \|\| `page.frontmatter.tag` | +| `article:published_time` | `page.frontmatter.date` \|\| `page.git.createdTime` | +| `article:modified_time` | `page.git.updatedTime` | + +### Default JSON-LD Generation + +| Property Name | Value | +| :-------------: | :---------------------------------------------------------------------------------------------------: | +| `@context` | `"https://schema.org"` | +| `@type` | `"NewsArticle"` | +| `headline` | `page.title` | +| `image` | image in page \|\| `options.hostname` + `page.frontmatter.image` \|\| `siteFavIcon` in plugin options | +| `datePublished` | `page.frontmatter.date` \|\| `page.git.createdTime` | +| `dateModified` | `page.git.updatedTime` | +| `author` | `page.frontmatter.author` \|\| `options.author` | + +## Setting Tags Directly + +You can configure the `head` option in the page's frontmatter to add specific tags to the page `` to enhance SEO. +For example: + +```md +--- +head: + - - meta + - name: keywords + content: SEO plugin +--- +``` + +Will automatically inject ``. + +## Customize Generation + +The plugin also gives you full control over the build logic. + +### Page Type + +For most pages, there are basically only two types: articles and website, so the plugin provides the `isArticle` option to allow you to provide logic for identifying articles. + +The option accepts a function in the format `(page: Page) => boolean`, by default all non-home pages generated from Markdown files are treated as articles. + +::: note + +If a page does fit into the "unpopular" genre like books, music, etc., you can handle them by setting the three options below. + +::: + +### OGP + +You can use the plugin options `ogp` to pass in a function to modify the default OGP object to your needs and return it. + +```ts +function ogp( + /** OGP Object inferred by plugin */ + ogp: SeoContent, + /** Page Object */ + page: Page, + /** VuePress App */ + app: App, +): SeoContent; +``` + +For detailed parameter structure, see [Config](./config.md). + +For example, if you are using a third-party theme and set a `banner` in frontmatter for each article according to the theme requirements, then you can pass in the following `ogp`: + +```ts +seoPlugin({ + ogp: (ogp, page) => ({ + ...ogp, + "og:image": page.frontmatter.banner || ogp["og:image"], + }), +}); +``` + +### JSON-LD + +Like OGP, you can use the plugin options `jsonLd` to pass in a function to modify the default JSON-LD object to your needs and return it. + +```ts +function jsonLd( + /** JSON-LD Object inferred by plugin */ + jsonLD: ArticleSchema | BlogPostingSchema | WebPageSchema, + /** Page Object */ + page: Page, + /** VuePress App */ + app: App, +): ArticleSchema | BlogPostingSchema | WebPageSchema; +``` + +## Canonical Link + +If you are deploying your content to different sites, or same content under different URLs, you may need to set `canonical` option to provide a "Canonical Link" for your page. You can either set a string which will be appended before page route link, or adding a custom function `(page: Page) => string | null` to return a canonical link if necessary. + +::: tip Example + +If your sites are deployed under docs directory in `example.com`, but available in: + +- `http://example.com/docs/xxx` +- `https://example.com/docs/xxx` +- `http://www.example.com/docs/xxx` +- `https://www.example.com/docs/xxx` (primary) + +To let search engine results always be the primary choice, you may need to set `canonical` to `https://www.example.com/docs/`, so that search engine will know that the fourth URL is preferred to be indexed. + +::: + +### Customize head Tags + +Sometimes you may need to fit other protocols or provide the corresponding SEO tags in the format provided by other search engines. In this case, you can use the `customHead` option, whose type is: + +```ts +function customHead( + /** Head tag config */ + head: HeadConfig[], + /** Page Object */ + page: Page, + /** VuePress App */ + app: App, +): void; +``` + +You should modify the `head` array in this function directly. + +## SEO Introduction + +**S**earch **e**ngine **optimization** (SEO) is the process of improving the quality and quantity of site traffic to a site or a web page from search engines. SEO targets unpaid traffic (known as "natural" or "organic" results) rather than direct traffic or paid traffic. Unpaid traffic may originate from different kinds of searches, including image search, video search, academic search, news search, and industry-specific vertical search engines. + +As an internet marketing strategy, SEO considers how search engines work, the computer-programmed algorithms that dictate search engine behavior, what people search for, the actual search terms or keywords typed into search engines, and which search engines are preferred by their targeted audience. SEO is performed because a site will receive more visitors from a search engine when sites rank higher on the search engine results page (SERP). These visitors can then potentially be converted into customers. + +## Related Documents + +- [Open Content Protocol OGP](https://ogp.me/) (**O**pen **G**raph **Pr**otocol) + + This plugin perfectly supports this protocol and will automatically generate `` tags that conform to the protocol. + +- [JSON-LD 1.1](https://www.w3.org/TR/json-ld-api/) + + This plugin will generate "NewsArticle" scheme for article pages. + +- [RDFa 1.1](https://www.w3.org/TR/rdfa-primer/) + + RDFa mainly marks HTML structure. This is what the plugin cannot support. vuepress-theme-hope uses this feature to pass Google's rich media structure test. You can consider using it. + +- [Schema.Org](https://schema.org/) + + Schema definition site for structural markup + +## Related Tools + +You can use [Google Rich Media Structure Test Tool](https://search.google.com/test/rich-results) to test this site. diff --git a/docs/themes/default/config.md b/docs/themes/default/config.md index 4a25de744..df5046c83 100644 --- a/docs/themes/default/config.md +++ b/docs/themes/default/config.md @@ -741,6 +741,16 @@ The generated link will look like `'https://gitlab.com/owner/name/-/edit/master/ Enable [@vuepress/plugin-nprogress](../../plugins/nprogress.md) or not. +### themePlugins.seo + +- Type: `boolean` + +- Default: `true` + +- Details: + + Enable [@vuepress/plugin-seo](../../plugins/seo/README.md) or not. + ### themePlugins.sitemap - Type: `boolean` diff --git a/docs/zh/plugins/seo/README.md b/docs/zh/plugins/seo/README.md new file mode 100644 index 000000000..2c32a5cca --- /dev/null +++ b/docs/zh/plugins/seo/README.md @@ -0,0 +1,21 @@ +# seo + + + +## 使用 + +```bash +npm i -D @vuepress/plugin-seo@next +``` + +```ts title=".vuepress/config.ts" +import { seoPlugin } from "@vuepress/plugin-seo"; + +export default { + plugins: [ + seoPlugin({ + // 选项 + }), + ], +} +``` diff --git a/docs/zh/plugins/seo/config.md b/docs/zh/plugins/seo/config.md new file mode 100644 index 000000000..8b4d22c62 --- /dev/null +++ b/docs/zh/plugins/seo/config.md @@ -0,0 +1,151 @@ +# 选项 + +## hostname + +- 类型:`string` +- 必填:是 +- 详情: + + 部署域名 + +## author + +- 类型:`Author` + + ```ts + type AuthorName = string; + + interface AuthorInfo { + /** + * 作者姓名 + */ + name: string; + + /** + * 作者网站 + */ + url?: string; + + /** + * 作者 Email + */ + email?: string; + } + + type Author = AuthorName | AuthorName[] | AuthorInfo | AuthorInfo[]; + ``` + +- 详情: + + 默认作者 + +## autoDescription + +- 类型:`boolean` + +- 默认值: `true` + +- 详情: + + 是否自动生成描述 + +## canonical + +- 类型:`string | ((page: Page) => string | null)` + +- 详情: + + 首选链接 + +## fallBackImage + +- 类型:`string` + +- 详情: + + 当找不到图片时的回退图片链接 + +## restrictions + +- 类型:`string` + +- 详情: + + 内容的年龄分级,格式为 `[int]+`,如 `"13+"` + +## twitterID + +- 类型:`string` + +- 详情: + + 你的 twitter 用户名 + +## isArticle + +- 类型:`(page: Page) => boolean` + +- 详情: + + 你可以使用此选项判断一个页面是否是文章。 + +## ogp + +- 类型: + + ```ts + function ogp( + /** 插件推断的 OGP 信息 */ + ogp: SeoContent, + /** 页面对象 */ + page: Page, + /** VuePress App */ + app: App, + ): SeoContent; + ``` + +- 详情: + + 自定义 OGP 生成器 + + 你可以使用此选项来注入新的或覆盖掉默认生成的 OGP 标签。 + +## jsonLd + +- 类型: + + ```ts + function jsonLd( + /** 由插件推断出的 JSON-LD 对象 */ + jsonLD: ArticleSchema | BlogPostingSchema | WebPageSchema, + /** 页面对象 */ + page: Page, + /** VuePress App */ + app: App, + ): ArticleSchema | BlogPostingSchema | WebPageSchema; + ``` + +- 详情: + + 自定义 JSON-LD 生成器 + + 你可以使用此选项来注入新的或覆盖掉默认生成的 JSON-LD 标签。 + +## customHead + +- 类型: + + ```ts + function customHead( + /** head 标签配置 */ + head: HeadConfig[], + /** 页面对象 */ + page: Page, + /** VuePress App */ + app: App, + ): void; + ``` + +- 详情: + + 你可以使用此选项来直接注入任意格式的标签到 ``。 diff --git a/docs/zh/plugins/seo/guide.md b/docs/zh/plugins/seo/guide.md new file mode 100644 index 000000000..2829f7520 --- /dev/null +++ b/docs/zh/plugins/seo/guide.md @@ -0,0 +1,179 @@ +# 指南 + +本插件会通过向网站 `` 注入标签,让你的网站完全支持 [开放内容协议 OGP](https://ogp.me/) 和 [JSON-LD 1.1](https://www.w3.org/TR/json-ld-api/),以全面增强站点的搜索引擎优化性。 + + + +## 开箱即用 + +插件开箱即用,在不做任何配置的情况下,会尽可能通过页面内容,提取对应的信息补全 OGP 与 JSON-LD 所需的必要标签。 + +默认情况下,插件会读取站点配置、主题配置与页面的 frontmatter 来尽可能自动生成。诸如站点名称,页面标题,页面类型,写作日期,最后更新日期,文章标签均会自动生成。 + +### 默认的 OGP 生成逻辑 + +| 属性名称 | 值 | +| :----------------------: | :------------------------------------------------------------------------------------------------: | +| `og:url` | `options.hostname` + `path` | +| `og:site_name` | `siteConfig.title` | +| `og:title` | `page.title` | +| `og:description` | `page.frontmatter.description` \|\| 自动生成 (当插件选项中的 `autoDescription` 为 `true` 时) | +| `og:type` | `"article"` | +| `og:image` | `options.hostname` + `page.frontmatter.image` \|\| 页面的第一张图片\|\| 插件选项的 `fallbackImage` | +| `og:updated_time` | `page.git.updatedTime` | +| `og:locale` | `page.lang` | +| `og:locale:alternate` | `siteData.locales` 包含的其他语言 | +| `twitter:card` | `"summary_large_image"` (仅在找到图片时) | +| `twitter:image:alt` | `page.title` (仅在找到图片时) | +| `article:author` | `page.frontmatter.author` \|\| `options.author` | +| `article:tag` | `page.frontmatter.tags` \|\| `page.frontmatter.tag` | +| `article:published_time` | `page.frontmatter.date` \|\| `page.git.createdTime` | +| `article:modified_time` | `page.git.updatedTime` | + +### 默认的 JSON-LD 生成逻辑 + +| 属性名 | 值 | +| :-------------: | :------------------------------------------------------------: | +| `@context` | `"https://schema.org"` | +| `@type` | `"NewsArticle"` | +| `headline` | `page.title` | +| `image` | 页面中的图片\|\| `options.hostname` + `page.frontmatter.image` | +| `datePublished` | `page.frontmatter.date` \|\| `page.git.createdTime` | +| `dateModified` | `page.git.updatedTime` | +| `author` | `page.frontmatter.author` \|\| `options.author` | + +## 直接添加 head 标签 + +你可以在页面的 frontmatter 中配置 `head` 选项,自主添加特定标签到页面 `` 以增强 SEO。 + +如: + +```md +--- +head: + - - meta + - name: keywords + content: SEO plugin +--- +``` + +会自动注入 ``。 + +## 自定义生成过程 + +本插件也支持你完全控制生成逻辑。 + +### 页面类型 + +对于大多数页面,基本只有文章和网页两种类型,所以插件提供了 `isArticle` 选项让你提供辨别文章的逻辑。 + +选项接受一个 `(page: Page) => boolean` 格式的函数,默认情况下从 Markdown 文件生成的非主页页面都会被视为文章。 + +::: note + +如果某个网页的确符合图书、音乐之类的“冷门”类型,你可以通过设置下方三个选项处理它们。 + +::: + +### OGP + +你可以使用插件选项的 `ogp` 传入一个函数来按照你的需要修改默认 OGP 对象并返回。 + +```ts +function ogp( + /** 插件推断的 OGP 信息 */ + ogp: SeoContent, + /** 页面对象 */ + page: Page, + /** VuePress App */ + app: App, +): SeoContent; +``` + +详细的参数结构详见 [配置](./config.md)。 + +比如你在使用某个第三方主题,并按照主题要求为每篇文章在 Front Matter 中设置了 `banner`,那你可以传入这样的 `ogp`: + +```ts +seoPlugin({ + ogp: (ogp, page) => ({ + ...ogp, + "og:image": page.frontmatter.banner || ogp["og:image"], + }), +}); +``` + +### JSON-LD + +同 OGP,你可以使用插件选项的 `jsonLd` 传入一个函数来按照你的需要修改默认 JSON-LD 对象并返回。 + +```ts +function jsonLd( + /** 由插件推断出的 JSON-LD 对象 */ + jsonLD: ArticleSchema | BlogPostingSchema | WebPageSchema, + /** 页面对象 */ + page: Page, + /** VuePress App */ + app: App, +): ArticleSchema | BlogPostingSchema | WebPageSchema; +``` + +## 规范链接 + +如果你将内容部署到不同的站点,或不同 URL 下的相同内容,你可能需要设置 `canonical` 选项为你的页面提供 “规范链接”。 你可以设置一个字符串,这样它会附加在页面路由链接之前,或者添加一个自定义函数 `(page: Page) => string | null` 返回规范链接。 + +::: tip 例子 + +如果你的站点部署在 `example.com` 的 docs 文件夹下,但同时在下列网址中可用: + +- `http://example.com/docs/xxx` +- `https://example.com/docs/xxx` +- `http://www.example.com/docs/xxx` +- `https://www.example.com/docs/xxx` (首选) + +要让搜索引擎结果始终是首选,你可能需要将 `canonical` 设置为 `https://www.example.com/docs/`,以便搜索引擎知道首选第四个 URL 作为索引结果。 + +::: + +### 自定义 head 标签 + +有些时候你可能需要符合其他协议或按照其他搜索引擎提供的格式提供对应的 SEO 标签,此时你可以使用 `customHead` 选项,其类型为: + +```ts +function customHead( + /** head 标签配置 */ + head: HeadConfig[], + /** 页面对象 */ + page: Page, + /** VuePress App */ + app: App, +): void; +``` + +你应该直接修改传入的 `head` 参数。 + +## SEO 介绍 + +搜索引擎优化 (**S**earch **E**ngine **O**ptimization),是一种透过了解搜索引擎的运作规则来调整网站,以及提高目的网站在有关搜索引擎内排名的方式。由于不少研究发现,搜索引擎的用户往往只会留意搜索结果最前面的几个条目,所以不少网站都希望透过各种形式来影响搜索引擎的排序,让自己的网站可以有优秀的搜索排名。 所谓“针对搜索引擎作最优化的处理”,是指为了要让网站更容易被搜索引擎接受。搜索引擎会将网站彼此间的内容做一些相关性的资料比对,然后再由浏览器将这些内容以最快速且接近最完整的方式,呈现给搜索者。搜索引擎优化就是通过搜索引擎的规则进行优化,为用户打造更好的用户体验,最终的目的就是做好用户体验。 + +## 相关文档 + +- [开放内容协议 OGP](https://ogp.me/) (**O**pen **G**raph **Pr**otocal) + + 本插件完美支持该协议,会自动生成符合该协议的 `` 标签。 + +- [JSON-LD 1.1](https://www.w3.org/TR/json-ld-api/) + + 本插件会为文章类页面生成 NewsArticle 类标签。 + +- [RDFa 1.1](https://www.w3.org/TR/rdfa-primer/) + + RDFa 主要标记 HTML 结构。这是插件无法支持的内容,vuepress-theme-hope 使用了这一功能通过了谷歌的富媒体结构测试。你可以考虑搭配使用。 + +- [Schema.Org](https://schema.org/) + + 结构标记的 Schema 定义站点 + +## 相关工具 + +你可以使用 [Google 富媒体结构测试工具](https://search.google.com/test/rich-results) 测试本站点。 diff --git a/docs/zh/themes/default/config.md b/docs/zh/themes/default/config.md index ef7aa51ec..d965abbd4 100644 --- a/docs/zh/themes/default/config.md +++ b/docs/zh/themes/default/config.md @@ -741,6 +741,16 @@ export default { 是否启用 [@vuepress/plugin-nprogress](../../plugins/nprogress.md) 。 +### themePlugins.seo + +- 类型: `boolean` + +- 默认值: `true` + +- 详情: + + 是否启用 [@vuepress/plugin-seo](../../plugins/seo/README.md) 。 + ### themePlugins.sitemap - 类型: `boolean` diff --git a/e2e/docs/.vuepress/config.ts b/e2e/docs/.vuepress/config.ts index b0e115dd3..ab48935b0 100644 --- a/e2e/docs/.vuepress/config.ts +++ b/e2e/docs/.vuepress/config.ts @@ -60,7 +60,6 @@ export default defineUserConfig({ ], sidebar: { - '/': ['/sidebar/'], '/sidebar/heading/': 'heading', '/sidebar/config/': [ { @@ -72,6 +71,7 @@ export default defineUserConfig({ ], }, ], + '/': [], }, themePlugins: { diff --git a/e2e/docs/seo/README.md b/e2e/docs/seo/README.md new file mode 100644 index 000000000..58eb2842a --- /dev/null +++ b/e2e/docs/seo/README.md @@ -0,0 +1,31 @@ +--- +title: SEO Demo Page +author: Mr.Hope +date: 2021-01-01 +category: + - Demo +tag: + - Demo +--- + +Here is **article excerpt**. + +```js +const a = 1; +``` + + + +## Content + +![alt](/logo.png) + +Here is main content of **article**. + +1. A +1. B +1. C + +```js +const a = 1; +``` diff --git a/e2e/tests/plugin-seo/seo.cy.ts b/e2e/tests/plugin-seo/seo.cy.ts new file mode 100644 index 000000000..cd340d3f7 --- /dev/null +++ b/e2e/tests/plugin-seo/seo.cy.ts @@ -0,0 +1,72 @@ +describe('seo', () => { + const BASE = Cypress.env('E2E_BASE') + + it('have OGP', () => { + cy.visit('/seo/') + + cy.get('head meta[property="og:url"]').should( + 'have.attr', + 'content', + `https://ecosystem-e2e-test.com${BASE}seo/`, + ) + cy.get('head meta[property="og:site_name"]').should( + 'have.attr', + 'content', + 'VuePress Ecosystem E2E', + ) + cy.get('head meta[property="og:title"]').should( + 'have.attr', + 'content', + 'SEO Demo Page', + ) + cy.get('head meta[property="og:description"]').should( + 'have.attr', + 'content', + 'Here is article excerpt. Content alt Here is main content of article. A B C ', + ) + cy.get('head meta[property="og:type"]').should( + 'have.attr', + 'content', + 'article', + ) + cy.get('head meta[property="og:locale"]').should( + 'have.attr', + 'content', + 'en-US', + ) + cy.get('head meta[property="article:author"]').should( + 'have.attr', + 'content', + 'Mr.Hope', + ) + cy.get('head meta[property="article:tag"]').should( + 'have.attr', + 'content', + 'Demo', + ) + cy.get('head meta[property="article:published_time"]').should( + 'have.attr', + 'content', + '2021-01-01T00:00:00.000Z', + ) + }) + + it('have JSONLD', () => { + cy.visit('/seo/') + + cy.get('head script[type="application/ld+json"]').then((el) => { + const json = JSON.parse(el[0].innerText) + + expect(json['@context']).to.equal('https://schema.org') + expect(json['@type']).to.equal('Article') + expect(json.headline).to.equal('SEO Demo Page') + expect(json.image).to.deep.equal([ + `https://ecosystem-e2e-test.com${BASE}logo.png`, + ]) + expect(json.datePublished).to.equal('2021-01-01T00:00:00.000Z') + expect(json).to.has.property('dateModified') + expect(json.author[0]['@type']).to.equal('Person') + expect(json.author[0].name).to.equal('Mr.Hope') + }) + }) +}) diff --git a/plugins/plugin-seo/package.json b/plugins/plugin-seo/package.json new file mode 100644 index 000000000..967445f83 --- /dev/null +++ b/plugins/plugin-seo/package.json @@ -0,0 +1,52 @@ +{ + "name": "@vuepress/plugin-seo", + "version": "2.0.0-rc.1", + "description": "SEO plugin for vuepress", + "keywords": [ + "vuepress", + "vuepress2", + "vuepress-plugin", + "seo" + ], + "homepage": "https://github.com/vuepress", + "bugs": { + "url": "https://github.com/vuepress/ecosystem/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vuepress/ecosystem.git", + "directory": "plugins/plugin-seo" + }, + "license": "MIT", + "author": { + "name": "Mr.Hope", + "email": "mister-hope@outlook.com", + "url": "https://mister-hope.com" + }, + "type": "module", + "exports": { + ".": "./lib/node/index.js", + "./package.json": "./package.json" + }, + "main": "./lib/node/index.js", + "types": "./lib/node/index.d.ts", + "files": [ + "lib" + ], + "scripts": { + "build": "tsc -b tsconfig.build.json", + "clean": "rimraf --glob ./lib ./*.tsbuildinfo" + }, + "dependencies": { + "@vuepress/helper": "workspace:*" + }, + "devDependencies": { + "@vuepress/plugin-git": "2.0.0-rc.1" + }, + "peerDependencies": { + "vuepress": "2.0.0-rc.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/plugins/plugin-seo/src/node/appendHead.ts b/plugins/plugin-seo/src/node/appendHead.ts new file mode 100644 index 000000000..c8090d6df --- /dev/null +++ b/plugins/plugin-seo/src/node/appendHead.ts @@ -0,0 +1,84 @@ +import { startsWith } from '@vuepress/helper' +import type { HeadConfig } from 'vuepress/core' +import type { + ArticleSchema, + ArticleSeoContent, + BlogPostingSchema, + SeoContent, + WebPageSchema, +} from '../typings/index.js' + +interface MetaOptions { + name: string + content: string + attribute?: string +} + +const appendMetaToHead = ( + head: HeadConfig[], + { + name, + content, + attribute = ['article:', 'og:'].some((type) => startsWith(name, type)) + ? 'property' + : 'name', + }: MetaOptions, +): void => { + if (content) head.push(['meta', { [attribute]: name, content }]) +} + +export const addOGP = (head: HeadConfig[], content: SeoContent): void => { + for (const property in content) + switch (property) { + case 'article:tag': + ;(content as ArticleSeoContent)['article:tag']!.forEach((tag: string) => + appendMetaToHead(head, { name: 'article:tag', content: tag }), + ) + break + case 'og:locale:alternate': + content['og:locale:alternate'].forEach((locale: string) => { + if (locale !== content['og:locale']) + appendMetaToHead(head, { + name: 'og:locale:alternate', + content: locale, + }) + }) + break + default: + if (content[property as keyof SeoContent] as string) + appendMetaToHead(head, { + name: property, + content: content[property as keyof SeoContent] as string, + }) + } +} + +export const appendJSONLD = ( + head: HeadConfig[], + content: ArticleSchema | BlogPostingSchema | WebPageSchema, +): void => { + head.push([ + 'script', + { type: 'application/ld+json' }, + JSON.stringify(content), + ]) +} + +export const appendCanonical = ( + head: HeadConfig[], + url?: string | null, +): void => { + if (url) head.push(['link', { rel: 'canonical', href: url }]) +} + +export const appendAlternate = ( + head: HeadConfig[], + urls: { lang: string; path: string }[], +): void => { + urls.forEach(({ lang, path }) => { + head.push([ + 'link', + { rel: 'alternate', hreflang: lang.toLowerCase(), href: path }, + ]) + }) +} diff --git a/plugins/plugin-seo/src/node/appendSEO.ts b/plugins/plugin-seo/src/node/appendSEO.ts new file mode 100644 index 000000000..34c861ec4 --- /dev/null +++ b/plugins/plugin-seo/src/node/appendSEO.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { App } from 'vuepress/core' +import type { ExtendPage } from '../typings/index.js' +import { + addOGP, + appendAlternate, + appendCanonical, + appendJSONLD, +} from './appendHead.js' +import { getJSONLDInfo } from './getJSONLDInfo.js' +import { getOGPInfo } from './getOGPInfo.js' +import type { SeoPluginOptions } from './options.js' +import { getAlternateLinks, getCanonicalLink } from './utils/getLinks.js' +import { logger } from './utils/index.js' + +export const appendSEO = (app: App, options: SeoPluginOptions): void => { + app.pages.forEach((page: ExtendPage) => { + const head = page.frontmatter.head || [] + + const canonicalLink = getCanonicalLink(page, options) + const alternateLinks = getAlternateLinks(app, page, options) + + appendCanonical(head, canonicalLink) + appendAlternate(head, alternateLinks) + + if (page.frontmatter.seo !== false) { + const defaultOGP = getOGPInfo(page, options, app) + const defaultJSONLD = getJSONLDInfo(page, options, app) + + const ogpContent = options.ogp + ? options.ogp(defaultOGP, page, app) + : defaultOGP + + const jsonLDContent = options.jsonLd + ? options.jsonLd(defaultJSONLD, page, app) + : defaultJSONLD + + if (app.env.isDebug) { + logger.info(`OGP of ${page.path}:`, ogpContent) + logger.info(`JSON-LD of ${page.path}:`, ogpContent) + } + + addOGP(head, ogpContent) + appendJSONLD(head, jsonLDContent) + + if (options.customHead) options.customHead(head, page, app) + } + + page.frontmatter.head = head + }) +} diff --git a/plugins/plugin-seo/src/node/generateDescription.ts b/plugins/plugin-seo/src/node/generateDescription.ts new file mode 100644 index 000000000..daa8b11a7 --- /dev/null +++ b/plugins/plugin-seo/src/node/generateDescription.ts @@ -0,0 +1,18 @@ +import { getPageText } from '@vuepress/helper' +import type { App } from 'vuepress/core' +import type { ExtendPage } from '../typings/index.js' + +export const generateDescription = ( + app: App, + page: ExtendPage, + autoDescription = true, +): void => { + // generate description + if (!page.frontmatter.description && autoDescription) { + const pageText = getPageText(app, page, { length: 180, singleLine: true }) + + page.frontmatter.description = + pageText.length > 180 ? `${pageText.slice(0, 177)}...` : pageText + page.data.autoDesc = true + } +} diff --git a/plugins/plugin-seo/src/node/generateRobotsTxt.ts b/plugins/plugin-seo/src/node/generateRobotsTxt.ts new file mode 100644 index 000000000..1ac9394aa --- /dev/null +++ b/plugins/plugin-seo/src/node/generateRobotsTxt.ts @@ -0,0 +1,24 @@ +import type { App } from 'vuepress/core' +import { fs } from 'vuepress/utils' +import { logger } from './utils/index.js' + +export const generateRobotsTxt = async (app: App): Promise => { + const { succeed, fail } = logger.load('Generating robots.txt') + const publicPath = app.dir.public('robots.txt') + + let content = fs.existsSync(publicPath) + ? await fs.readFile(publicPath, { encoding: 'utf8' }) + : '' + + if (content && !content.includes('User-agent')) { + fail('robots.txt seems invalid!') + } else { + content += '\nUser-agent:*\nDisallow:\n' + + await fs.writeFile(app.dir.dest('robots.txt'), content, { + flag: 'w', + }) + + succeed() + } +} diff --git a/plugins/plugin-seo/src/node/getJSONLDInfo.ts b/plugins/plugin-seo/src/node/getJSONLDInfo.ts new file mode 100644 index 000000000..f47b823f5 --- /dev/null +++ b/plugins/plugin-seo/src/node/getJSONLDInfo.ts @@ -0,0 +1,55 @@ +import { parseDate } from '@vuepress/helper' +import type { App } from 'vuepress/core' +import type { + ArticleSchema, + BlogPostingSchema, + ExtendPage, + WebPageSchema, +} from '../typings/index.js' +import type { SeoPluginOptions } from './options.js' +import { getCover, getImages, getSEOAuthor } from './utils/index.js' + +export const getJSONLDInfo = ( + page: ExtendPage, + options: SeoPluginOptions, + app: App, +): ArticleSchema | BlogPostingSchema | WebPageSchema => { + const { + isArticle = (page): boolean => + Boolean(page.filePathRelative && !page.frontmatter.home), + author: globalAuthor, + } = options + + const { + title, + frontmatter: { author: pageAuthor, description, time, date = time }, + data: { git = {} }, + } = page + + const author = getSEOAuthor(pageAuthor || globalAuthor) + const datePublished = parseDate(date)?.value?.toISOString() + const dateModified = git.updatedTime + ? new Date(git.updatedTime).toISOString() + : null + const cover = getCover(page, app, options) + const images = getImages(page, app, options) + + return isArticle(page) + ? { + '@context': 'https://schema.org', + '@type': 'Article', + 'headline': title, + 'image': images.length + ? images + : [cover || options.fallBackImage || ''], + datePublished, + dateModified, + 'author': author.map((item) => ({ '@type': 'Person', ...item })), + } + : { + '@context': 'https://schema.org', + '@type': 'WebPage', + 'name': title, + ...(description ? { description } : {}), + } +} diff --git a/plugins/plugin-seo/src/node/getOGPInfo.ts b/plugins/plugin-seo/src/node/getOGPInfo.ts new file mode 100644 index 000000000..437611a25 --- /dev/null +++ b/plugins/plugin-seo/src/node/getOGPInfo.ts @@ -0,0 +1,85 @@ +import { isArray, isString, parseDate } from '@vuepress/helper' +import type { App } from 'vuepress/core' +import type { ExtendPage, SeoContent } from '../typings/index.js' +import type { SeoPluginOptions } from './options.js' +import { + getAlternatePaths, + getCover, + getImages, + getSEOAuthor, + getUrl, +} from './utils/index.js' + +export const getOGPInfo = ( + page: ExtendPage, + options: SeoPluginOptions, + app: App, +): SeoContent => { + const { + isArticle = (page): boolean => + Boolean(page.filePathRelative && !page.frontmatter.home), + author: globalAuthor, + } = options + const { + options: { base }, + siteData, + } = app + const { + frontmatter: { + author: pageAuthor, + time, + date = time, + tag, + tags = tag as string[], + }, + data: { git = {} }, + } = page + + const title = + siteData.locales[page.pathLocale]?.title || + siteData.title || + siteData.locales['/']?.title || + '' + const author = getSEOAuthor(pageAuthor || globalAuthor) + const modifiedTime = git.updatedTime + ? new Date(git.updatedTime).toISOString() + : null + const articleTags = isArray(tags) ? tags : isString(tag) ? [tag] : [] + const articleTitle = page.title + const cover = getCover(page, app, options) + const images = getImages(page, app, options) + const locales = getAlternatePaths(page, app) + const publishedTime = parseDate(date)?.value?.toISOString() + + const ogImage = cover || images[0] || options.fallBackImage || '' + + const defaultOGP: SeoContent = { + 'og:url': getUrl(options.hostname, base, page.path), + 'og:site_name': title, + 'og:title': articleTitle, + 'og:description': page.frontmatter.description || '', + 'og:type': isArticle(page) ? 'article' : 'website', + 'og:image': ogImage, + 'og:locale': page.lang, + 'og:locale:alternate': locales.map(({ lang }) => lang), + ...(modifiedTime ? { 'og:updated_time': modifiedTime } : {}), + ...(options.restrictions + ? { 'og:restrictions:age': options.restrictions } + : {}), + + ...(options.twitterID ? { 'twitter:creator': options.twitterID } : {}), + ...(ogImage + ? { + 'twitter:card': 'summary_large_image', + 'twitter:image:alt': articleTitle, + } + : {}), + + 'article:author': author[0]?.name, + 'article:tag': articleTags, + ...(publishedTime ? { 'article:published_time': publishedTime } : {}), + ...(modifiedTime ? { 'article:modified_time': modifiedTime } : {}), + } + + return defaultOGP +} diff --git a/plugins/plugin-seo/src/node/index.ts b/plugins/plugin-seo/src/node/index.ts new file mode 100644 index 000000000..31c98491b --- /dev/null +++ b/plugins/plugin-seo/src/node/index.ts @@ -0,0 +1,3 @@ +export * from './options.js' +export * from './seoPlugin.js' +export * from '../typings/index.js' diff --git a/plugins/plugin-seo/src/node/options.ts b/plugins/plugin-seo/src/node/options.ts new file mode 100644 index 000000000..9c302c52b --- /dev/null +++ b/plugins/plugin-seo/src/node/options.ts @@ -0,0 +1,184 @@ +import type { App, HeadConfig } from 'vuepress/core' +import type { + ArticleSchema, + BlogPostingSchema, + ExtendPage, + SeoAuthor, + SeoContent, + WebPageSchema, +} from '../typings/index.js' + +export interface SeoPluginOptions { + /** + * Deploy hostname + * + * 部署域名 + */ + hostname: string + + /** + * Default author + * + * 默认作者 + */ + author?: SeoAuthor + + /** + * Content restrictions + * + * The age rating of the content, the format is `[int]+`, such as `"13+"` + * + * 内容分级情况 + * + * 内容的年龄分级,格式为`[int]+`,如`"13+"` + */ + restrictions?: string + + /** + * Whether generate description automatically + * + * 是否自动生成描述 + * + * @default true + */ + autoDescription?: boolean + + /** + * Fallback Image link when no image are found + * + * @description should be full or absolute links, probably your site favicon + * + * 当找不到图片时的回退图片链接 + * + * @description 应为完整或绝对链接,你可以设置为站点图标 + */ + fallBackImage?: string + + /** + * Twitter username + * + * Twitter 用户名 + */ + twitterID?: string + + /** + * Whether the page is an article + * + * 页面是否是文章 + */ + isArticle?: < + ExtraPageData extends Record = Record, + ExtraPageFrontmatter extends Record = Record< + string, + unknown + >, + ExtraPageFields extends Record = Record, + >( + page: ExtendPage, + ) => boolean + + /** + * Custom OGP Generator + * + * 自定义 OGP 生成器 + */ + ogp?: < + ExtraPageData extends Record = Record, + ExtraPageFrontmatter extends Record = Record< + string, + unknown + >, + ExtraPageFields extends Record = Record, + >( + /** + * OGP Object inferred by plugin + * + * 由插件推断出的 OGP 对象 + */ + ogp: SeoContent, + /** + * Page Object + * + * 页面对象 + */ + page: ExtendPage, + /** VuePress App */ + app: App, + ) => SeoContent + + /** + * Custom JSON-LD Generator + * + * 自定义 JSON-LD 生成器 + */ + jsonLd?: < + ExtraPageData extends Record = Record, + ExtraPageFrontmatter extends Record = Record< + string, + unknown + >, + ExtraPageFields extends Record = Record, + >( + /** + * JSON-LD Object inferred by plugin + * + * 由插件推断出的 JSON-LD 对象 + */ + jsonLD: ArticleSchema | BlogPostingSchema | WebPageSchema, + /** + * Page Object + * + * 页面对象 + */ + page: ExtendPage, + /** VuePress App */ + app: App, + ) => ArticleSchema | BlogPostingSchema | WebPageSchema + + /** + * Custom head tags + * + * 自定义 Head 标签 + */ + customHead?: < + ExtraPageData extends Record = Record, + ExtraPageFrontmatter extends Record = Record< + string, + unknown + >, + ExtraPageFields extends Record = Record, + >( + /** + * Head tag config + * + * head 标签配置 + */ + head: HeadConfig[], + /** + * Page Object + * + * 页面对象 + */ + page: ExtendPage, + /** VuePress App */ + app: App, + ) => void + + /** + * Add canonical URL + * + * 添加首选地址 + */ + canonical?: + | string + | (< + ExtraPageData extends Record = Record, + ExtraPageFrontmatter extends Record = Record< + string, + unknown + >, + ExtraPageFields extends Record = Record, + >( + page: ExtendPage, + ) => string | null) +} diff --git a/plugins/plugin-seo/src/node/seoPlugin.ts b/plugins/plugin-seo/src/node/seoPlugin.ts new file mode 100644 index 000000000..d2e9dbbb0 --- /dev/null +++ b/plugins/plugin-seo/src/node/seoPlugin.ts @@ -0,0 +1,37 @@ +import type { Plugin, PluginFunction } from 'vuepress/core' +import { colors } from 'vuepress/utils' +import type { ExtendPage } from '../typings/index.js' +import { appendSEO } from './appendSEO.js' +import { generateDescription } from './generateDescription.js' +import { generateRobotsTxt } from './generateRobotsTxt.js' +import type { SeoPluginOptions } from './options.js' +import { logger, PLUGIN_NAME } from './utils/index.js' + +export const seoPlugin = + (options: SeoPluginOptions): PluginFunction => + (app) => { + if (app.env.isDebug) logger.info('Options:', options) + + const plugin: Plugin = { name: PLUGIN_NAME } + + if (!options.hostname) { + logger.error(`Option ${colors.magenta('hostname')} is required!`) + + return plugin + } + + return { + ...plugin, + + extendsPage: (page: ExtendPage): void => { + if (page.frontmatter.seo !== false) + generateDescription(app, page, options.autoDescription !== false) + }, + + onInitialized: (app): void => { + appendSEO(app, options) + }, + + onGenerated: (app): Promise => generateRobotsTxt(app), + } + } diff --git a/plugins/plugin-seo/src/node/utils/getAlternatePaths.ts b/plugins/plugin-seo/src/node/utils/getAlternatePaths.ts new file mode 100644 index 000000000..a11e3c81e --- /dev/null +++ b/plugins/plugin-seo/src/node/utils/getAlternatePaths.ts @@ -0,0 +1,23 @@ +import { entries, isString } from '@vuepress/helper/node' +import type { App, Page } from 'vuepress/core' + +export interface AlternatePath { + path: string + lang: string +} + +export const getAlternatePaths = ( + { lang, path, pathLocale }: Page, + { pages, siteData }: App, +): AlternatePath[] => + entries(siteData.locales) + .map(([localePath, { lang }]) => ({ + path: `${localePath}${path.replace(pathLocale, '')}`, + lang, + })) + .filter( + (item): item is AlternatePath => + isString(item.lang) && + item.lang !== lang && + pages.some(({ path }) => path === item.path), + ) diff --git a/plugins/plugin-seo/src/node/utils/getAuthor.ts b/plugins/plugin-seo/src/node/utils/getAuthor.ts new file mode 100644 index 000000000..db074806c --- /dev/null +++ b/plugins/plugin-seo/src/node/utils/getAuthor.ts @@ -0,0 +1,31 @@ +import { isArray, isPlainObject, isString } from '@vuepress/helper/node' +import type { AuthorInfo, SeoAuthor } from '../../typings/index.js' + +const isSEOAuthor = (author: unknown): author is SeoAuthor => + isPlainObject(author) && isString(author.name) + +export const getSEOAuthor = ( + author: SeoAuthor | false | undefined, +): AuthorInfo[] => { + if (author) { + if (isArray(author)) + return author + .map((item) => + isString(item) ? { name: item } : isSEOAuthor(item) ? item : null, + ) + .filter((item): item is AuthorInfo => item !== null) + + if (isString(author)) return [{ name: author }] + + if (isSEOAuthor(author)) return [author] + + console.error( + `Expect "author" to be \`AuthorInfo[] | AuthorInfo | string[] | string | undefined\`, but got`, + author, + ) + + return [] + } + + return [] +} diff --git a/plugins/plugin-seo/src/node/utils/getCover.ts b/plugins/plugin-seo/src/node/utils/getCover.ts new file mode 100644 index 000000000..678d23005 --- /dev/null +++ b/plugins/plugin-seo/src/node/utils/getCover.ts @@ -0,0 +1,27 @@ +import { isAbsoluteUrl, isUrl } from '@vuepress/helper/node' +import type { App } from 'vuepress/core' +import type { ExtendPage } from '../../typings/index.js' +import type { SeoPluginOptions } from '../options.js' +import { getUrl } from './getUrl.js' + +export const getCover = ( + { frontmatter }: ExtendPage, + { options: { base } }: App, + { hostname }: SeoPluginOptions, +): string | null => { + const { banner, cover } = frontmatter + + if (banner) { + if (isAbsoluteUrl(banner)) return getUrl(hostname, base, banner) + + if (isUrl(banner)) return banner + } + + if (cover) { + if (isAbsoluteUrl(cover)) return getUrl(hostname, base, cover) + + if (isUrl(cover)) return cover + } + + return null +} diff --git a/plugins/plugin-seo/src/node/utils/getImages.ts b/plugins/plugin-seo/src/node/utils/getImages.ts new file mode 100644 index 000000000..5e176637f --- /dev/null +++ b/plugins/plugin-seo/src/node/utils/getImages.ts @@ -0,0 +1,24 @@ +import { isAbsoluteUrl, isUrl } from '@vuepress/helper/node' +import type { App } from 'vuepress/core' +import type { ExtendPage } from '../../typings/index.js' +import type { SeoPluginOptions } from '../options.js' +import { getUrl } from './getUrl.js' + +const IMAGE_REG_EXP = /!\[.*?\]\((.*?)\)/gu + +export const getImages = ( + { content }: ExtendPage, + { options: { base } }: App, + { hostname }: SeoPluginOptions, +): string[] => + Array.from(content.matchAll(IMAGE_REG_EXP)) + .map(([, link]) => { + console.log(link) + + if (isAbsoluteUrl(link)) return getUrl(hostname, base, link) + + if (isUrl(link)) return link + + return null + }) + .filter((item): item is string => item !== null) diff --git a/plugins/plugin-seo/src/node/utils/getLinks.ts b/plugins/plugin-seo/src/node/utils/getLinks.ts new file mode 100644 index 000000000..ca983b5aa --- /dev/null +++ b/plugins/plugin-seo/src/node/utils/getLinks.ts @@ -0,0 +1,27 @@ +import { isFunction, isString } from '@vuepress/helper' +import type { App } from 'vuepress/core' +import { removeEndingSlash } from 'vuepress/shared' +import type { ExtendPage } from '../../typings/index.js' +import type { SeoPluginOptions } from '../options.js' +import { getAlternatePaths } from './getAlternatePaths.js' +import { getUrl } from './getUrl.js' + +export const getCanonicalLink = ( + page: ExtendPage, + options: SeoPluginOptions, +): string | null => + isFunction(options.canonical) + ? options.canonical(page) + : isString(options.canonical) + ? `${removeEndingSlash(options.canonical)}${page.path}` + : null + +export const getAlternateLinks = ( + app: App, + page: ExtendPage, + { hostname }: SeoPluginOptions, +): { lang: string; path: string }[] => + getAlternatePaths(page, app).map(({ lang, path }) => ({ + lang, + path: getUrl(hostname, app.options.base, path), + })) diff --git a/plugins/plugin-seo/src/node/utils/getUrl.ts b/plugins/plugin-seo/src/node/utils/getUrl.ts new file mode 100644 index 000000000..4fa0d3993 --- /dev/null +++ b/plugins/plugin-seo/src/node/utils/getUrl.ts @@ -0,0 +1,10 @@ +import { + isLinkHttp, + removeEndingSlash, + removeLeadingSlash, +} from 'vuepress/shared' + +export const getUrl = (hostname: string, base: string, url: string): string => + `${removeEndingSlash( + isLinkHttp(hostname) ? hostname : `https://${hostname}`, + )}${base}${removeLeadingSlash(url)}` diff --git a/plugins/plugin-seo/src/node/utils/index.ts b/plugins/plugin-seo/src/node/utils/index.ts new file mode 100644 index 000000000..49260443f --- /dev/null +++ b/plugins/plugin-seo/src/node/utils/index.ts @@ -0,0 +1,6 @@ +export * from './getAlternatePaths.js' +export * from './getAuthor.js' +export * from './getCover.js' +export * from './getImages.js' +export * from './getUrl.js' +export * from './logger.js' diff --git a/plugins/plugin-seo/src/node/utils/logger.ts b/plugins/plugin-seo/src/node/utils/logger.ts new file mode 100644 index 000000000..7b0b21a38 --- /dev/null +++ b/plugins/plugin-seo/src/node/utils/logger.ts @@ -0,0 +1,5 @@ +import { Logger } from '@vuepress/helper/node' + +export const PLUGIN_NAME = 'vuepress-plugin-seo2' + +export const logger = new Logger(PLUGIN_NAME) diff --git a/plugins/plugin-seo/src/typings/frontmatter.ts b/plugins/plugin-seo/src/typings/frontmatter.ts new file mode 100644 index 000000000..263918229 --- /dev/null +++ b/plugins/plugin-seo/src/typings/frontmatter.ts @@ -0,0 +1,56 @@ +import type { PageFrontmatter } from 'vuepress/core' + +export type AuthorName = string + +export interface AuthorInfo { + /** + * Author name + * + * 作者姓名 + */ + name: string + + /** + * Author website + * + * 作者网站 + */ + url?: string + + /** + * Author email + * + * 作者 Email + */ + email?: string +} + +export type SeoAuthor = AuthorName | AuthorName[] | AuthorInfo | AuthorInfo[] + +export interface SEOPluginFrontmatter extends PageFrontmatter { + /** + * Whether inject seo information for current page + * + * @default true + */ + seo?: boolean + + /** + * Feed author + */ + author?: SeoAuthor + + /** + * Page Cover + * + * 页面封面 + */ + cover?: string + + /** + * Page Banner + * + * 页面 Banner 图 + */ + banner?: string +} diff --git a/plugins/plugin-seo/src/typings/helper.ts b/plugins/plugin-seo/src/typings/helper.ts new file mode 100644 index 000000000..5fee65359 --- /dev/null +++ b/plugins/plugin-seo/src/typings/helper.ts @@ -0,0 +1,22 @@ +import type { GitData } from '@vuepress/plugin-git' +import type { Page } from 'vuepress/core' +import type { SEOPluginFrontmatter } from './frontmatter.js' + +export interface SeoPluginPageData { + autoDesc?: true + excerpt?: string + git?: GitData +} + +export type ExtendPage< + ExtraPageData extends Record = Record, + ExtraPageFrontmatter extends Record = Record< + string, + unknown + >, + ExtraPageFields extends Record = Record, +> = Page< + ExtraPageData & SeoPluginPageData, + ExtraPageFrontmatter & SEOPluginFrontmatter, + ExtraPageFields +> diff --git a/plugins/plugin-seo/src/typings/index.ts b/plugins/plugin-seo/src/typings/index.ts new file mode 100644 index 000000000..5086f1bd7 --- /dev/null +++ b/plugins/plugin-seo/src/typings/index.ts @@ -0,0 +1,4 @@ +export * from './frontmatter.js' +export * from './helper.js' +export * from './ogp.js' +export * from './schema.js' diff --git a/plugins/plugin-seo/src/typings/ogp.ts b/plugins/plugin-seo/src/typings/ogp.ts new file mode 100644 index 000000000..9cacbb619 --- /dev/null +++ b/plugins/plugin-seo/src/typings/ogp.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/plugins/plugin-seo/src/typings/schema.ts b/plugins/plugin-seo/src/typings/schema.ts new file mode 100644 index 000000000..81019ea45 --- /dev/null +++ b/plugins/plugin-seo/src/typings/schema.ts @@ -0,0 +1,91 @@ +/** + * @see https://schema.org/Person + */ + +export interface PersonSchema extends Record { + '@type': 'Person' + + /** + * Person name + */ + 'name': string + + /** + * Person URL + * @recommended + */ + 'url'?: string +} + +/** + * @see https://schema.org/CreativeWork + * + * @tutorial https://developers.google.com/search/docs/appearance/structured-data/article#structured-data-type-definitions + */ +export interface CreativeWorkSchema extends Record { + '@type': 'CreativeWork' + + /** + * An abstract is a short description that summarizes a CreativeWork + */ + 'abstract'?: string + + /** + * The author of this content or rating + */ + 'author': PersonSchema[] + + /** + * Text of a notice appropriate for describing the copyright aspects of this Creative Work, ideally indicating the owner of the copyright for the Work + */ + 'copyrightNotice'?: string + + /** + * Article title + * + * @description No more than 110 characters + */ + 'headline': string + + /** + * @recommended + */ + 'datePublished'?: string + /** + * @recommended + */ + 'dateModified'?: string + + 'wordCount'?: number +} + +/** + * @see https://schema.org/Article + * + * @tutorial https://developers.google.com/search/docs/appearance/structured-data/article#structured-data-type-definitions + */ +export interface ArticleSchema extends Omit { + '@context': 'https://schema.org' + '@type': 'Article' +} + +/** + * @see https://schema.org/BlogPosting + * + * @tutorial https://developers.google.com/search/docs/appearance/structured-data/article#structured-data-type-definitions + */ +export interface BlogPostingSchema extends Omit { + '@context': 'https://schema.org' + '@type': 'BlogPosting' +} + +/** + * @see https://schema.org/WebPage + */ +export interface WebPageSchema extends Omit { + '@context': 'https://schema.org' + '@type': 'WebPage' + + 'name'?: string + 'description'?: string +} diff --git a/plugins/plugin-seo/tests/__fixtures__/src/README.md b/plugins/plugin-seo/tests/__fixtures__/src/README.md new file mode 100644 index 000000000..bea3a479a --- /dev/null +++ b/plugins/plugin-seo/tests/__fixtures__/src/README.md @@ -0,0 +1,5 @@ +--- +home: true +--- + +This is a home page. diff --git a/plugins/plugin-seo/tests/__fixtures__/src/description.md b/plugins/plugin-seo/tests/__fixtures__/src/description.md new file mode 100644 index 000000000..461a1dfd0 --- /dev/null +++ b/plugins/plugin-seo/tests/__fixtures__/src/description.md @@ -0,0 +1,6 @@ +--- +title: Description Test +description: This page is used to test the description of the page. +--- + +Page content. diff --git a/plugins/plugin-seo/tests/__fixtures__/src/example.md b/plugins/plugin-seo/tests/__fixtures__/src/example.md new file mode 100644 index 000000000..b12304de1 --- /dev/null +++ b/plugins/plugin-seo/tests/__fixtures__/src/example.md @@ -0,0 +1,114 @@ +# Heading 1 + +## Heading 2 + +### Heading 3 + +#### Heading 4 + +##### Heading 5 + +###### Heading 6 + +## Text + +This sentence has **bold**、_italic_ and ~~delete~~ style text. + +## Paragraph + +This is a paragraph. + +This is another paragraph. + +## Line Break + +I would like to line break at +this point + +::: tip + +In codes above, two spaces are behind `at`. + +::: + +## Blockquotes + +> Blockquotes can also be nested... +> +> > ...by using greater-than signs right next to each other... +> > +> > > ...or with spaces between arrows. + +## List + +### Unordered List + +- Create a list by starting a line with `-` +- Make sub-lists by indenting 2 spaces: + + - Marker character change forces new list start: + + - Ac tristique libero volutpat at + - Facilisis in pretium nisl aliquet + - Nulla volutpat aliquam velit + link break + + New paragraph + +- It’s easy! + +### Ordered List + +1. Lorem ipsum dolor sit amet +1. Consectetur adipiscing elit + line break + line break again +1. Integer molestie lorem at massa + +## HR + +--- + +## Link + +[Home page using absolute path](/) + +[Home page using relative path](../../README.md) + +## Image + +![Logo](/logo.png) + +## Emoji + +Classic: + +:wink: :cry: :laughing: :yum: + +## Tables + +| center | right | left | +| :------------------------: | -----------------------: | :---------------------- | +| For center align use `:-:` | For right align use `-:` | For left align use `:-` | +| b | aaaaaaaaa | aaaa | +| c | aaaa | a | + +## Codes + +Inline Code: `code` + +Block code: + +``` +Sample text here... +``` + +Syntax highlighting: + +```js +var foo = function (bar) { + return bar++; +}; + +console.log(foo(5)); +``` diff --git a/plugins/plugin-seo/tests/__fixtures__/src/zh/README.md b/plugins/plugin-seo/tests/__fixtures__/src/zh/README.md new file mode 100644 index 000000000..f06726f21 --- /dev/null +++ b/plugins/plugin-seo/tests/__fixtures__/src/zh/README.md @@ -0,0 +1,5 @@ +--- +home: true +--- + +这是一个主页。 diff --git a/plugins/plugin-seo/tests/__fixtures__/src/zh/description.md b/plugins/plugin-seo/tests/__fixtures__/src/zh/description.md new file mode 100644 index 000000000..2a7a3b959 --- /dev/null +++ b/plugins/plugin-seo/tests/__fixtures__/src/zh/description.md @@ -0,0 +1,6 @@ +--- +title: 描述测试 +description: 这个页面用于测试页面描述 +--- + +页面内容。 diff --git a/plugins/plugin-seo/tests/__fixtures__/src/zh/example.md b/plugins/plugin-seo/tests/__fixtures__/src/zh/example.md new file mode 100644 index 000000000..0ddcdb491 --- /dev/null +++ b/plugins/plugin-seo/tests/__fixtures__/src/zh/example.md @@ -0,0 +1,123 @@ +# 一级标题 + +## 二级标题 + +### 三级标题 + +#### 四级标题 + +##### 五级标题 + +###### 六级标题 + +## Text + +这句话里拥有**加粗**、*倾斜*和~~删除~~ + +## 段落 + +这是一个段落。 + +这是另一个段落。 + +## 换行 + +这是一句话不过我要在这里 +换行 + +::: tip + +上方的代码中 `这里` 后面有两个空格 + +::: + +## 引用 + +> 引用也可以连用 +> +> > 可以添加额外的大于号制造更深的引用 + +## 列表 + +### 无序列表 + +- 无序列表项 +- 无序列表项 + + - 列表中的列表项 + - 更多的列表项 + - 更多的列表项 + - 更多的列表项 + - 列表中的长列表项,这个列表项很长。 + + 而且由很多个段落构成。 + + 甚至最后一个段落还包含了[链接](#链接)。 + +- 无序列表项 + +### 有序列表 + +1. 有序列表第一项 +1. 有序列表第二项 + 第二项的需要换行 + 再次换行 +1. 有序列表第三项 + +::: tip + +上方的代码中`换行`后面有也两个空格 + +::: + +## 分割线 + +--- + +## 链接 + +[根目录访问主页](/v2/) + +[相对路径访问主页](../../README.md) + +[根目录访问示例](/v2/demo) + +[相对路径访问示例](../../demo.md) + +## 图片 + +![Logo](/logo.png) + +## Emoji + +经典方式: + +:wink: :cry: :laughing: :yum: + +## 表格 + +| 居中 | 右对齐 | 左对齐 | +| :-----------: | -------------: | :------------- | +| 居中使用`:-:` | 右对齐使用`-:` | 左对齐使用`:-` | +| b | aaaaaaaaa | aaaa | +| c | aaaa | a | + +## 代码 + +行内代码效果: `code` + +块级代码 + +```md +Sample text here... +``` + +高亮格式: + +```js +var foo = function (bar) { + return bar++; +}; + +console.log(foo(5)); +``` diff --git a/plugins/plugin-seo/tests/__fixtures__/theme/empty.ts b/plugins/plugin-seo/tests/__fixtures__/theme/empty.ts new file mode 100644 index 000000000..ebf455fd7 --- /dev/null +++ b/plugins/plugin-seo/tests/__fixtures__/theme/empty.ts @@ -0,0 +1,5 @@ +import type { Theme } from 'vuepress/core' + +export const emptyTheme: Theme = { + name: 'vuepress-theme-empty', +} diff --git a/plugins/plugin-seo/tests/node/__snapshots__/description.spec.ts.snap b/plugins/plugin-seo/tests/node/__snapshots__/description.spec.ts.snap new file mode 100644 index 000000000..5b82d15e5 --- /dev/null +++ b/plugins/plugin-seo/tests/node/__snapshots__/description.spec.ts.snap @@ -0,0 +1,483 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Should generate seo information > Should contain basic properties 1`] = ` +[ + [ + "link", + { + "href": "https://exmaple.com/", + "rel": "canonical", + }, + ], + [ + "link", + { + "href": "https://exmaple.com/zh/", + "hreflang": "zh-cn", + "rel": "alternate", + }, + ], + [ + "meta", + { + "content": "https://exmaple.com/", + "property": "og:url", + }, + ], + [ + "meta", + { + "content": "This is a home page. ", + "property": "og:description", + }, + ], + [ + "meta", + { + "content": "website", + "property": "og:type", + }, + ], + [ + "meta", + { + "content": "en-US", + "property": "og:locale", + }, + ], + [ + "meta", + { + "content": "zh-CN", + "property": "og:locale:alternate", + }, + ], + [ + "script", + { + "type": "application/ld+json", + }, + "{"@context":"https://schema.org","@type":"WebPage","name":"","description":"This is a home page. "}", + ], +] +`; + +exports[`Should generate seo information > Should contain basic properties 2`] = ` +[ + [ + "link", + { + "href": "https://exmaple.com/description.html", + "rel": "canonical", + }, + ], + [ + "link", + { + "href": "https://exmaple.com/zh/description.html", + "hreflang": "zh-cn", + "rel": "alternate", + }, + ], + [ + "meta", + { + "content": "https://exmaple.com/description.html", + "property": "og:url", + }, + ], + [ + "meta", + { + "content": "Description Test", + "property": "og:title", + }, + ], + [ + "meta", + { + "content": "This page is used to test the description of the page.", + "property": "og:description", + }, + ], + [ + "meta", + { + "content": "article", + "property": "og:type", + }, + ], + [ + "meta", + { + "content": "en-US", + "property": "og:locale", + }, + ], + [ + "meta", + { + "content": "zh-CN", + "property": "og:locale:alternate", + }, + ], + [ + "script", + { + "type": "application/ld+json", + }, + "{"@context":"https://schema.org","@type":"Article","headline":"Description Test","image":[""],"dateModified":null,"author":[]}", + ], +] +`; + +exports[`Should generate seo information > Should contain basic properties 3`] = ` +[ + [ + "link", + { + "href": "https://exmaple.com/example.html", + "rel": "canonical", + }, + ], + [ + "link", + { + "href": "https://exmaple.com/zh/example.html", + "hreflang": "zh-cn", + "rel": "alternate", + }, + ], + [ + "meta", + { + "content": "https://exmaple.com/example.html", + "property": "og:url", + }, + ], + [ + "meta", + { + "content": "Heading 1", + "property": "og:title", + }, + ], + [ + "meta", + { + "content": "Heading 1 Heading 2 Heading 3 Heading 4 Heading 5 Heading 6 Text This sentence has bold、italic and style text. Paragraph This is a paragraph. This is another paragraph. Line Bre...", + "property": "og:description", + }, + ], + [ + "meta", + { + "content": "article", + "property": "og:type", + }, + ], + [ + "meta", + { + "content": "https://exmaple.com/logo.png", + "property": "og:image", + }, + ], + [ + "meta", + { + "content": "en-US", + "property": "og:locale", + }, + ], + [ + "meta", + { + "content": "zh-CN", + "property": "og:locale:alternate", + }, + ], + [ + "meta", + { + "content": "summary_large_image", + "name": "twitter:card", + }, + ], + [ + "meta", + { + "content": "Heading 1", + "name": "twitter:image:alt", + }, + ], + [ + "script", + { + "type": "application/ld+json", + }, + "{"@context":"https://schema.org","@type":"Article","headline":"Heading 1","image":["https://exmaple.com/logo.png"],"dateModified":null,"author":[]}", + ], +] +`; + +exports[`Should generate seo information > Should contain basic properties 4`] = ` +[ + [ + "link", + { + "href": "https://exmaple.com/zh/", + "rel": "canonical", + }, + ], + [ + "link", + { + "href": "https://exmaple.com/", + "hreflang": "en-us", + "rel": "alternate", + }, + ], + [ + "meta", + { + "content": "https://exmaple.com/zh/", + "property": "og:url", + }, + ], + [ + "meta", + { + "content": "这是一个主页。 ", + "property": "og:description", + }, + ], + [ + "meta", + { + "content": "website", + "property": "og:type", + }, + ], + [ + "meta", + { + "content": "zh-CN", + "property": "og:locale", + }, + ], + [ + "meta", + { + "content": "en-US", + "property": "og:locale:alternate", + }, + ], + [ + "script", + { + "type": "application/ld+json", + }, + "{"@context":"https://schema.org","@type":"WebPage","name":"","description":"这是一个主页。 "}", + ], +] +`; + +exports[`Should generate seo information > Should contain basic properties 5`] = ` +[ + [ + "link", + { + "href": "https://exmaple.com/zh/description.html", + "rel": "canonical", + }, + ], + [ + "link", + { + "href": "https://exmaple.com/description.html", + "hreflang": "en-us", + "rel": "alternate", + }, + ], + [ + "meta", + { + "content": "https://exmaple.com/zh/description.html", + "property": "og:url", + }, + ], + [ + "meta", + { + "content": "描述测试", + "property": "og:title", + }, + ], + [ + "meta", + { + "content": "这个页面用于测试页面描述", + "property": "og:description", + }, + ], + [ + "meta", + { + "content": "article", + "property": "og:type", + }, + ], + [ + "meta", + { + "content": "zh-CN", + "property": "og:locale", + }, + ], + [ + "meta", + { + "content": "en-US", + "property": "og:locale:alternate", + }, + ], + [ + "script", + { + "type": "application/ld+json", + }, + "{"@context":"https://schema.org","@type":"Article","headline":"描述测试","image":[""],"dateModified":null,"author":[]}", + ], +] +`; + +exports[`Should generate seo information > Should contain basic properties 6`] = ` +[ + [ + "link", + { + "href": "https://exmaple.com/zh/example.html", + "rel": "canonical", + }, + ], + [ + "link", + { + "href": "https://exmaple.com/example.html", + "hreflang": "en-us", + "rel": "alternate", + }, + ], + [ + "meta", + { + "content": "https://exmaple.com/zh/example.html", + "property": "og:url", + }, + ], + [ + "meta", + { + "content": "一级标题", + "property": "og:title", + }, + ], + [ + "meta", + { + "content": "一级标题 二级标题 三级标题 四级标题 五级标题 六级标题 Text 这句话里拥有加粗、倾斜和 段落 这是一个段落。 这是另一个段落。 换行 这是一句话不过我要在这里 换行 ::: tip 上方的代码中 这里 后面有两个空格 ::: 引用 引用也可以连用 可以添加额外的大于号制造更深的引用 列表 无序列表 无序列表项 无序列表项 列表中的列表项 更多的...", + "property": "og:description", + }, + ], + [ + "meta", + { + "content": "article", + "property": "og:type", + }, + ], + [ + "meta", + { + "content": "https://exmaple.com/logo.png", + "property": "og:image", + }, + ], + [ + "meta", + { + "content": "zh-CN", + "property": "og:locale", + }, + ], + [ + "meta", + { + "content": "en-US", + "property": "og:locale:alternate", + }, + ], + [ + "meta", + { + "content": "summary_large_image", + "name": "twitter:card", + }, + ], + [ + "meta", + { + "content": "一级标题", + "name": "twitter:image:alt", + }, + ], + [ + "script", + { + "type": "application/ld+json", + }, + "{"@context":"https://schema.org","@type":"Article","headline":"一级标题","image":["https://exmaple.com/logo.png"],"dateModified":null,"author":[]}", + ], +] +`; + +exports[`Should generate seo information > Should contain basic properties 7`] = ` +[ + [ + "link", + { + "href": "https://exmaple.com/404.html", + "rel": "canonical", + }, + ], + [ + "meta", + { + "content": "https://exmaple.com/404.html", + "property": "og:url", + }, + ], + [ + "meta", + { + "content": "website", + "property": "og:type", + }, + ], + [ + "meta", + { + "content": "en-US", + "property": "og:locale", + }, + ], + [ + "script", + { + "type": "application/ld+json", + }, + "{"@context":"https://schema.org","@type":"WebPage","name":""}", + ], +] +`; diff --git a/plugins/plugin-seo/tests/node/description.spec.ts b/plugins/plugin-seo/tests/node/description.spec.ts new file mode 100644 index 000000000..08b6699d5 --- /dev/null +++ b/plugins/plugin-seo/tests/node/description.spec.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { describe, expect, it } from 'vitest' +import { createBaseApp } from 'vuepress/core' +import { path } from 'vuepress/utils' +import { seoPlugin } from '../../src/node/index.js' +import { emptyTheme } from '../__fixtures__/theme/empty.js' + +const app = createBaseApp({ + bundler: {} as any, + source: path.resolve(__dirname, '../__fixtures__/src'), + theme: emptyTheme, + locales: { + '/': { + lang: 'en-US', + }, + '/zh/': { + lang: 'zh-CN', + }, + }, + plugins: [ + seoPlugin({ + hostname: 'https://exmaple.com', + canonical: 'https://exmaple.com', + }), + ], +}) + +await app.init() + +describe('Should generate seo information', () => { + it('Should contain basic properties', () => { + app.pages.forEach(({ frontmatter }) => { + expect(frontmatter.head).toMatchSnapshot() + }) + }) +}) diff --git a/plugins/plugin-seo/tsconfig.build.json b/plugins/plugin-seo/tsconfig.build.json new file mode 100644 index 000000000..e0a82d817 --- /dev/null +++ b/plugins/plugin-seo/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "types": ["vuepress/client-types"] + }, + "include": ["./src"], + "references": [{ "path": "../../tools/helper/tsconfig.build.json" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8658390eb..7d9a3043b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -390,6 +390,19 @@ importers: specifier: 2.0.0-rc.2 version: 2.0.0-rc.2(@vuepress/bundler-vite@2.0.0-rc.2)(@vuepress/bundler-webpack@2.0.0-rc.2)(typescript@5.3.3)(vue@3.4.15) + plugins/plugin-seo: + dependencies: + '@vuepress/helper': + specifier: workspace:* + version: link:../../tools/helper + vuepress: + specifier: 2.0.0-rc.2 + version: 2.0.0-rc.2(@vuepress/bundler-vite@2.0.0-rc.2)(@vuepress/bundler-webpack@2.0.0-rc.2)(typescript@5.3.3)(vue@3.4.15) + devDependencies: + '@vuepress/plugin-git': + specifier: 2.0.0-rc.1 + version: link:../plugin-git + plugins/plugin-shiki: dependencies: shikiji: @@ -468,6 +481,9 @@ importers: '@vuepress/plugin-prismjs': specifier: workspace:* version: link:../../plugins/plugin-prismjs + '@vuepress/plugin-seo': + specifier: workspace:* + version: link:../../plugins/plugin-seo '@vuepress/plugin-sitemap': specifier: workspace:* version: link:../../plugins/plugin-sitemap diff --git a/themes/theme-default/package.json b/themes/theme-default/package.json index 12b8cb508..329b4bf01 100644 --- a/themes/theme-default/package.json +++ b/themes/theme-default/package.json @@ -51,6 +51,7 @@ "@vuepress/plugin-nprogress": "workspace:*", "@vuepress/plugin-palette": "workspace:*", "@vuepress/plugin-prismjs": "workspace:*", + "@vuepress/plugin-seo": "workspace:*", "@vuepress/plugin-sitemap": "workspace:*", "@vuepress/plugin-theme-data": "workspace:*", "@vueuse/core": "^10.7.2", diff --git a/themes/theme-default/src/node/defaultTheme.ts b/themes/theme-default/src/node/defaultTheme.ts index a49a0a4e7..15bf483ba 100644 --- a/themes/theme-default/src/node/defaultTheme.ts +++ b/themes/theme-default/src/node/defaultTheme.ts @@ -7,6 +7,7 @@ import { mediumZoomPlugin } from '@vuepress/plugin-medium-zoom' import { nprogressPlugin } from '@vuepress/plugin-nprogress' import { palettePlugin } from '@vuepress/plugin-palette' import { prismjsPlugin } from '@vuepress/plugin-prismjs' +import { seoPlugin } from '@vuepress/plugin-seo' import { sitemapPlugin } from '@vuepress/plugin-sitemap' import { themeDataPlugin } from '@vuepress/plugin-theme-data' import type { Page, Theme } from 'vuepress/core' @@ -169,6 +170,14 @@ export const defaultTheme = ({ // @vuepress/plugin-prismjs themePlugins.prismjs !== false ? prismjsPlugin() : [], + // @vuepress/plugin-seo + hostname && themePlugins.seo !== false + ? seoPlugin({ + hostname, + ...(isPlainObject(themePlugins.seo) ? themePlugins.seo : {}), + }) + : [], + // @vuepress/plugin-sitemap hostname && themePlugins.sitemap !== false ? sitemapPlugin({ diff --git a/themes/theme-default/src/shared/options.ts b/themes/theme-default/src/shared/options.ts index ad3ac6975..301e9871c 100644 --- a/themes/theme-default/src/shared/options.ts +++ b/themes/theme-default/src/shared/options.ts @@ -1,3 +1,4 @@ +import type { SeoPluginOptions } from '@vuepress/plugin-seo' import type { SitemapPluginOptions } from '@vuepress/plugin-sitemap' import type { ThemeData } from '@vuepress/plugin-theme-data' import type { LocaleData } from 'vuepress/shared' @@ -51,6 +52,14 @@ export interface DefaultThemePluginsOptions { */ prismjs?: boolean + /** + * Enable @vuepress/plugin-seo or not + */ + seo?: Partial | boolean + + /** + * Enable @vuepress/plugin-sitemap or not + */ sitemap?: Partial | boolean } diff --git a/themes/theme-default/tsconfig.build.json b/themes/theme-default/tsconfig.build.json index 2736dcc8d..82ba71d4f 100644 --- a/themes/theme-default/tsconfig.build.json +++ b/themes/theme-default/tsconfig.build.json @@ -20,6 +20,7 @@ { "path": "../../plugins/plugin-nprogress/tsconfig.build.json" }, { "path": "../../plugins/plugin-palette/tsconfig.build.json" }, { "path": "../../plugins/plugin-prismjs/tsconfig.build.json" }, + { "path": "../../plugins/plugin-seo/tsconfig.build.json" }, { "path": "../../plugins/plugin-sitemap/tsconfig.build.json" }, { "path": "../../plugins/plugin-theme-data/tsconfig.build.json" } ] diff --git a/tsconfig.build.json b/tsconfig.build.json index 3c2256aaf..dba64c1be 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -31,6 +31,7 @@ "path": "./plugins/plugin-register-components/tsconfig.build.json" }, { "path": "./plugins/plugin-search/tsconfig.build.json" }, + { "path": "./plugins/plugin-seo/tsconfig.build.json" }, { "path": "./plugins/plugin-shiki/tsconfig.build.json" }, { "path": "./plugins/plugin-sitemap/tsconfig.build.json" }, { "path": "./plugins/plugin-theme-data/tsconfig.build.json" },