diff --git a/docs/plugins/development/git.md b/docs/plugins/development/git.md
index c578630055..d456e36dfb 100644
--- a/docs/plugins/development/git.md
+++ b/docs/plugins/development/git.md
@@ -2,7 +2,7 @@
-This plugin will collect git information of your pages, including the created and updated time, the contributors, etc.
+This plugin will collect git information of your pages, including the created and updated time, the contributors, the changelog, etc.
The [lastUpdated](../../themes/default/config.md#lastupdated) and [contributors](../../themes/default/config.md#contributors) of default theme is powered by this plugin.
@@ -60,7 +60,62 @@ This plugin will significantly slow down the speed of data preparation, especial
### contributors
-- Type: `boolean`
+- Type: `boolean | ContributorsOptions`
+
+ ```ts
+ interface ContributorsOptions {
+ /**
+ * Functions to transform contributors, e.g. remove duplicates ones and sort them.
+ * The input is the contributors collected by this plugin, and the output should be the transformed contributors.
+ */
+ transform?: (
+ contributors: GitContributor[],
+ ) => GitContributor[] | Promise
+
+ /**
+ * List of contributors configurations
+ */
+ list?: ContributorConfig[]
+
+ /**
+ * Whether to add avatar in contributor information
+ * @default false
+ */
+ avatar?: boolean
+ }
+
+ interface ContributorConfig {
+ /**
+ * Contributor's username on the git hosting service
+ */
+ username: string
+ /**
+ * Contributor name displayed on the page, default is `username`
+ */
+ name?: string
+ /**
+ * The alias of the contributor,
+ * Since contributors may have different usernames saved in their local git configuration
+ * compared to their usernames on the hosting service, In this case, aliases can be used to
+ * map to the actual usernames.
+ */
+ alias?: string[] | string
+ /**
+ * The avatar url of the contributor.
+ *
+ * If the git hosting service is `github`, it can be ignored and left blank,
+ * as the plugin will automatically fill it in.
+ */
+ avatar?: string
+ /**
+ * The url of the contributor
+ *
+ * If the git hosting service is `github`, it can be ignored and left blank,
+ * as the plugin will automatically fill it in.
+ */
+ url?: string
+ }
+ ```
- Default: `true`
@@ -68,17 +123,64 @@ This plugin will significantly slow down the speed of data preparation, especial
Whether to collect page contributors or not.
-### transformContributors
-
-- Type: `(contributors: GitContributor[]) => GitContributor[]`
+### changelog
+
+- Type: `false | ChangelogOptions`
+
+ ```ts
+ interface ChangelogOptions {
+ /**
+ * Maximum number of changelog
+ */
+ maxCount?: number
+
+ /**
+ * The url of the git repository, e.g: https://github.com/vuepress/ecosystem
+ */
+ repoUrl?: string
+
+ /**
+ * Commit url pattern
+ * Default: ':repo/commit/:hash'
+ *
+ * - `:repo` - The url of the git repository
+ * - `:hash` - Hash of the commit record
+ */
+ commitUrlPattern?: string
+
+ /**
+ * Issue url pattern
+ * Default: ':repo/issues/:issue'
+ *
+ * - `:repo` - The url of the git repository
+ * - `:issue` - Id of the issue
+ */
+ issueUrlPattern?: string
+
+ /**
+ * Tag url pattern
+ * Default: ':repo/releases/tag/:tag'
+ *
+ * - `:repo` - The url of the git repository
+ * - `:tag` - Name of the tag
+ */
+ tagUrlPattern?: string
+ }
+ ```
+
+- Default: `false`
- Details:
- A function to transform the contributors.
+ Whether to collect page changelog or not.
+
+### filter
+
+- Type: `(page: Page) => boolean`
- The input is the contributors collected by this plugin, and the output should be the transformed contributors.
+- Details:
- You can use it to filter out some contributors, or to sort contributors.
+ Page filter, if it returns `true`, the page will collect git information.
## Frontmatter
@@ -100,6 +202,26 @@ gitInclude:
---
```
+### contributors
+
+- Type: `boolean | string[]`
+
+- Details:
+
+ Whether to collect contributor information for the current page, this value will override the [contributors](#contributors) configuration item.
+
+ - `true` - Collect contributor information
+ - `false` - Do not collect contributor information
+ - `string[]` - List of additional contributors, sometimes there are additional contributors on the page, and this configuration item can be used to specify the list of additional contributors to obtain contributor information
+
+### changelog
+
+- Type: `boolean`
+
+- Details:
+
+ Whether to collect the change history for the current page, this value will override the [changelog](#changelog) configuration item.
+
## Page Data
This plugin will add a `git` field to page data.
@@ -147,6 +269,8 @@ interface GitContributor {
name: string
email: string
commits: number
+ avatar?: string
+ url?: string
}
```
@@ -155,3 +279,46 @@ interface GitContributor {
The contributors information of the page.
This attribute would also include contributors to the files listed in [gitInclude](#gitinclude).
+
+### git.changelog
+
+- 类型: `GitChangelog[]`
+
+```ts
+interface GitChangelog {
+ /**
+ * Commit hash
+ */
+ hash: string
+ /**
+ * Unix timestamp in milliseconds
+ */
+ date: number
+ /**
+ * Commit message
+ */
+ message: string
+ /**
+ * Commit author name
+ */
+ author: string
+ /**
+ * Commit author email
+ */
+ email: string
+ /**
+ * The url of the commit
+ */
+ commitUrl?: string
+ /**
+ * The url of the release tag
+ */
+ tagUrl?: string
+}
+```
+
+- Details:
+
+ The changelog of the page.
+
+ This attribute would also include contributors to the files listed in [gitInclude](#gitinclude).
diff --git a/docs/zh/plugins/development/git.md b/docs/zh/plugins/development/git.md
index 92962c8993..3480e0c1a0 100644
--- a/docs/zh/plugins/development/git.md
+++ b/docs/zh/plugins/development/git.md
@@ -2,7 +2,7 @@
-该插件会收集你的页面的 Git 信息,包括创建和更新时间、贡献者等。
+该插件会收集你的页面的 Git 信息,包括创建和更新时间、贡献者、变更历史记录等。
默认主题的 [lastUpdated](../../themes/default/config.md#lastupdated) 和 [contributors](../../themes/default/config.md#contributors) 就是由该插件支持的。
@@ -60,7 +60,56 @@ export default {
### contributors
-- 类型: `boolean`
+- 类型: `boolean | ContributorsOptions`
+
+ ```ts
+ interface ContributorsOptions {
+ /**
+ * 贡献者转换函数,例如去重和排序
+ * 该函数接收一个贡献者信息数组,返回一个新的贡献者信息数组。
+ */
+ transform?: (
+ contributors: GitContributor[],
+ ) => GitContributor[] | Promise
+
+ /**
+ * 贡献者配置
+ */
+ list?: ContributorConfig[]
+
+ /**
+ * 是否在贡献者信息中添加头像
+ * @default false
+ */
+ avatar?: boolean
+ }
+
+ interface ContributorConfig {
+ /**
+ * 贡献者在 git 托管服务中的用户名
+ */
+ username: string
+ /**
+ * 贡献者显示在页面上的名字, 默认为 `username`
+ */
+ name?: string
+ /**
+ * 贡献者别名, 由于贡献者可能在本地 git 配置中保存的 用户名与 git 托管服务用户名不一致,
+ * 这时候可以通过别名映射到真实的用户名
+ */
+ alias?: string[] | string
+ /**
+ * 贡献者头像地址
+ * 如果 git 托管服务为 `github`,则可以忽略不填,由插件自动填充
+ */
+ avatar?: string
+ /**
+ * 贡献者访问地址
+ * 如果 git 托管服务为 `github`,则可以忽略不填,由插件自动填充
+ */
+ url?: string
+ }
+ ```
- 默认值: `true`
@@ -68,17 +117,64 @@ export default {
是否收集页面的贡献者。
-### transformContributors
-
-- 类型: `(contributors: GitContributor[]) => GitContributor[]`
+### changelog
+
+- 类型: `false | ChangelogOptions`
+
+ ```ts
+ interface ChangelogOptions {
+ /**
+ * 最大变更记录条数
+ */
+ maxCount?: number
+
+ /**
+ * git 仓库的访问地址,例如:https://github.com/vuepress/ecosystem
+ */
+ repoUrl?: string
+
+ /**
+ * 提交记录访问地址模式
+ * 默认值:':repo/commit/:hash'
+ *
+ * - `:repo` - git 仓库的访问地址
+ * - `:hash` - 提交记录的 hash
+ */
+ commitUrlPattern?: string
+
+ /**
+ * issue 访问地址模式
+ * 默认值:':repo/issues/:issue'
+ *
+ * - `:repo` - git 仓库的访问地址
+ * - `:issue` - issue 的 id
+ */
+ issueUrlPattern?: string
+
+ /**
+ * tag 访问地址模式,
+ * 默认值:':repo/releases/tag/:tag'
+ *
+ * - `:repo` - git 仓库的访问地址
+ * - `:tag` - tag 的名称
+ */
+ tagUrlPattern?: string
+ }
+ ```
+
+- 默认值: `false`
- 详情:
- 贡献者信息的转换函数。
+ 是否收集页面变更历史记录。
+
+### filter
+
+- 类型: `(page: Page) => boolean`
- 该函数接收一个贡献者信息数组,返回一个新的贡献者信息数组。
+- 详情:
- 你可以使用该函数来过滤贡献者或排序贡献者。
+ 页面过滤器,如果返回 `true` ,该页面将收集 git 信息
## Frontmatter
@@ -100,6 +196,26 @@ gitInclude:
---
```
+### contributors
+
+- 类型: `boolean | string[]`
+
+- 详情:
+
+ 当前页面是否获取贡献者信息,该值会覆盖 [contributors](#contributors) 配置项。
+
+ - `true` - 获取贡献者信息
+ - `false` - 不获取贡献者信息
+ - `string[]` - 额外的贡献者名单,有时候页面存在额外的贡献者,可以通过这个配置项来指定额外的贡献者名单来获取贡献者信息
+
+### changelog
+
+- 类型: `boolean`
+
+- 详情:
+
+ 当前页面是否获取变更历史记录,该值会覆盖 [changelog](#changelog) 配置项。
+
## 页面数据
该插件会向页面数据中添加一个 `git` 字段。
@@ -147,6 +263,8 @@ interface GitContributor {
name: string
email: string
commits: number
+ avatar?: string
+ url?: string
}
```
@@ -155,3 +273,46 @@ interface GitContributor {
页面的贡献者信息。
该属性将会包含 [gitInclude](#gitinclude) 所列文件的贡献者。
+
+### git.changelog
+
+- 类型: `GitChangelog[]`
+
+```ts
+interface GitChangelog {
+ /**
+ * 提交 hash
+ */
+ hash: string
+ /**
+ * Unix 时间戳,单位毫秒,提交时间
+ */
+ date: number
+ /**
+ * 提交信息
+ */
+ message: string
+ /**
+ * 提交者名称
+ */
+ author: string
+ /**
+ * 提交者邮箱
+ */
+ email: string
+ /**
+ * 提交访问地址
+ */
+ commitUrl?: string
+ /**
+ * tag 访问地址
+ */
+ tagUrl?: string
+}
+```
+
+- 详情:
+
+ 页面的变更历史记录。
+
+ 该属性将会包含 [gitInclude](#gitinclude) 所列文件的变更历史记录。
diff --git a/plugins/development/plugin-git/package.json b/plugins/development/plugin-git/package.json
index 8ff648ade8..c2ff49570f 100644
--- a/plugins/development/plugin-git/package.json
+++ b/plugins/development/plugin-git/package.json
@@ -35,7 +35,7 @@
"clean": "rimraf --glob ./lib ./*.tsbuildinfo"
},
"dependencies": {
- "execa": "^9.4.1"
+ "execa": "^9.5.1"
},
"peerDependencies": {
"vuepress": "2.0.0-rc.18"
diff --git a/plugins/development/plugin-git/src/node/gitPlugin.ts b/plugins/development/plugin-git/src/node/gitPlugin.ts
index 9fa16e37a7..5955cecfee 100644
--- a/plugins/development/plugin-git/src/node/gitPlugin.ts
+++ b/plugins/development/plugin-git/src/node/gitPlugin.ts
@@ -1,52 +1,33 @@
import type { Page, Plugin } from 'vuepress/core'
+import { isPlainObject } from 'vuepress/shared'
import { path } from 'vuepress/utils'
import type {
- GitContributor,
GitPluginFrontmatter,
+ GitPluginOptions,
GitPluginPageData,
} from './types.js'
import {
checkGitRepo,
- getContributors,
- getCreatedTime,
- getUpdatedTime,
+ getCommits,
+ inferGitProvider,
+ resolveChangelog,
+ resolveContributors,
} from './utils/index.js'
-/**
- * Options of @vuepress/plugin-git
- */
-export interface GitPluginOptions {
- /**
- * Whether to get the created time of a page
- */
- createdTime?: boolean
-
- /**
- * Whether to get the updated time of a page
- */
- updatedTime?: boolean
-
- /**
- * Whether to get the contributors of a page
- */
- contributors?: boolean
-
- /**
- * Functions to transform contributors, e.g. remove duplicates ones and sort them
- */
- transformContributors?: (contributors: GitContributor[]) => GitContributor[]
-}
-
export const gitPlugin =
({
createdTime,
updatedTime,
contributors,
+ changelog = false,
+ filter,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
transformContributors,
}: GitPluginOptions = {}): Plugin =>
(app) => {
const cwd = app.dir.source()
const isGitRepoValid = checkGitRepo(cwd)
+ const gitProvider = isGitRepoValid ? inferGitProvider(cwd) : null
return {
name: '@vuepress/plugin-git',
@@ -60,6 +41,20 @@ export const gitPlugin =
return
}
+ if (filter && !filter(page)) return
+
+ const { frontmatter } = page
+
+ // skip if all features are disabled
+ if (
+ !(frontmatter.contributors ?? contributors ?? true) &&
+ !(frontmatter.changelog ?? changelog) &&
+ createdTime === false &&
+ updatedTime === false
+ ) {
+ return
+ }
+
const filePaths = [
page.filePathRelative,
...(page.frontmatter.gitInclude ?? []).map((item) =>
@@ -67,18 +62,43 @@ export const gitPlugin =
),
]
+ const commits = await getCommits(filePaths, cwd)
+
+ if (commits.length === 0) return
+
if (createdTime !== false) {
- page.data.git.createdTime = await getCreatedTime(filePaths, cwd)
+ page.data.git.createdTime = commits[commits.length - 1].date
}
if (updatedTime !== false) {
- page.data.git.updatedTime = await getUpdatedTime(filePaths, cwd)
+ page.data.git.updatedTime = commits[0].date
}
- if (contributors !== false) {
- const result = await getContributors(filePaths, cwd)
+ if ((frontmatter.contributors ?? contributors) !== false) {
+ const options = isPlainObject(contributors) ? contributors : {}
+ options.transform ??= transformContributors
+ page.data.git.contributors = await resolveContributors(
+ commits,
+ options,
+ gitProvider,
+ Array.isArray(frontmatter.contributors)
+ ? frontmatter.contributors
+ : [],
+ )
+ }
- page.data.git.contributors = transformContributors?.(result) ?? result
+ if (frontmatter.changelog ?? changelog) {
+ const changelogOptions = isPlainObject(changelog) ? changelog : {}
+ const contributorsOptions = isPlainObject(contributors)
+ ? contributors
+ : {}
+ page.data.git.changelog = resolveChangelog(
+ app,
+ commits,
+ changelogOptions,
+ gitProvider,
+ contributorsOptions.list ?? [],
+ )
}
},
diff --git a/plugins/development/plugin-git/src/node/types.ts b/plugins/development/plugin-git/src/node/types.ts
index 908bd0f262..f0cd498368 100644
--- a/plugins/development/plugin-git/src/node/types.ts
+++ b/plugins/development/plugin-git/src/node/types.ts
@@ -1,7 +1,198 @@
-import type { PageFrontmatter } from 'vuepress'
+import type { Page, PageFrontmatter } from 'vuepress'
+
+export interface GitPluginOptions {
+ /**
+ * Page filter, if it returns `true`, the page will collect git information.
+ *
+ * 页面过滤器,如果返回 `true` ,该页面将收集 git 信息
+ */
+ filter?: (page: Page) => boolean
+ /**
+ * Whether to get the created time of a page
+ *
+ * 是否收集页面创建时间
+ */
+ createdTime?: boolean
+
+ /**
+ * Whether to get the updated time of a page
+ *
+ * 是否收集页面更新时间
+ */
+ updatedTime?: boolean
+
+ /**
+ * Whether to get the contributors of a page
+ *
+ * 是否收集页面的贡献者
+ */
+ contributors?: ContributorsOptions | boolean
+
+ /**
+ * Whether to get the changelog of a page
+ *
+ * 是否收集页面的变更历史记录
+ */
+ changelog?: ChangelogOptions | false
+
+ /**
+ * @deprecated use `contributors.transform` instead
+ * Functions to transform contributors, e.g. remove duplicates ones and sort them
+ */
+ transformContributors?: (contributors: GitContributor[]) => GitContributor[]
+}
+
+export interface ContributorsOptions {
+ /**
+ * Functions to transform contributors, e.g. remove duplicates ones and sort them
+ *
+ * 贡献者转换函数,例如去重和排序
+ */
+ transform?: (
+ contributors: GitContributor[],
+ ) => GitContributor[] | Promise
+
+ /**
+ * The list of contributors configurations
+ *
+ * 贡献者配置
+ */
+ list?: ContributorConfig[]
+
+ /**
+ * Whether to add avatar in contributor information
+ *
+ * 是否在贡献者信息中添加头像
+ *
+ * @default false
+ */
+ avatar?: boolean
+}
+
+export interface ContributorConfig {
+ /**
+ * Contributor's username on the git hosting service
+ *
+ * 贡献者在 Git 托管服务中的用户名
+ */
+ username: string
+
+ /**
+ * Contributor name displayed on the page, default is `username`
+ *
+ * 贡献者显示在页面上的名字, 默认为 `username`
+ */
+ name?: string
+
+ /**
+ * The alias of the contributor,
+ * Since contributors may have different usernames saved in their local git configuration
+ * compared to their usernames on the hosting service, In this case, aliases can be used to
+ * map to the actual usernames.
+ *
+ * 贡献者别名, 由于贡献者可能在本地 git 配置中保存的 用户名与 托管服务 用户名不一致,
+ * 这时候可以通过别名映射到真实的用户名
+ */
+ alias?: string[] | string
+ /**
+ * The avatar url of the contributor.
+ *
+ * If the git hosting service is `github`, it can be ignored and left blank, as the plugin will automatically fill it in.
+ *
+ * 贡献者头像地址
+ *
+ * 如果 git 托管服务为 `github`,则可以忽略不填,由插件自动填充
+ */
+ avatar?: string
+ /**
+ * The url of the contributor
+ *
+ * If the git hosting service is `github`, it can be ignored and left blank, as the plugin will automatically fill it in.
+ *
+ * 贡献者访问地址
+ *
+ * 如果 git 托管服务为 `github`,则可以忽略不填,由插件自动填充
+ */
+ url?: string
+}
+
+export interface ChangelogOptions {
+ /**
+ * Maximum number of changelog
+ *
+ * 最大变更记录条数
+ */
+ maxCount?: number
+
+ /**
+ * The url of the git repository, e.g: https://github.com/vuepress/ecosystem
+ *
+ * git 仓库的访问地址,例如:https://github.com/vuepress/ecosystem
+ */
+ repoUrl?: string
+
+ /**
+ * Commit url pattern
+ *
+ * - `:repo` - The url of the git repository
+ * - `:hash` - Hash of the commit record
+ *
+ * 提交记录访问地址模式
+ *
+ * - `:repo` - git 仓库的访问地址
+ * - `:hash` - 提交记录的 hash
+ *
+ * @default ':repo/commit/:hash'
+ */
+ commitUrlPattern?: string
+
+ /**
+ * Issue url pattern
+ *
+ * - `:repo` - The url of the git repository
+ * - `:issue` - Id of the issue
+ *
+ * issue 访问地址模式
+ *
+ * - `:repo` - git 仓库的访问地址
+ * - `:issue` - issue 的 id
+ *
+ * @default ':repo/issues/:issue'
+ */
+ issueUrlPattern?: string
+
+ /**
+ * Tag url pattern
+ *
+ * - `:repo` - The url of the git repository
+ * - `:tag` - Name of the tag
+ *
+ * tag 访问地址模式,
+ * 默认值:':repo/releases/tag/:tag'
+ *
+ * - `:repo` - git 仓库的访问地址
+ * - `:tag` - tag 的名称
+ *
+ * @default ':repo/releases/tag/:tag'
+ */
+ tagUrlPattern?: string
+}
export interface GitPluginFrontmatter extends PageFrontmatter {
gitInclude?: string[]
+
+ /**
+ * Whether to get the contributors of a page
+ *
+ * - If the value is `false`, it will be ignored
+ * - If the value is `string[]`, it will be used as the list of extra contributors
+ */
+ contributors?: string[] | boolean
+
+ /**
+ * Whether to get the changelog of a page
+ */
+ changelog?: boolean
}
export interface GitPluginPageData extends Record {
@@ -23,10 +214,71 @@ export interface GitData {
* Contributors of all commits
*/
contributors?: GitContributor[]
+
+ /**
+ * Changelog of a page
+ */
+ changelog?: GitChangelog[]
}
export interface GitContributor {
name: string
email: string
commits: number
+ avatar?: string
+ url?: string
+}
+
+export type KnownGitProvider = 'bitbucket' | 'gitee' | 'github' | 'gitlab'
+
+export interface RawCommit {
+ filepath: string
+ /**
+ * Commit hash
+ */
+ hash: string
+ /**
+ * Unix timestamp in milliseconds
+ */
+ date: number
+ /**
+ * Commit message
+ */
+ message: string
+ /**
+ * Commit message body
+ */
+ body: string
+ /**
+ * Commit refs
+ */
+ refs: string
+ /**
+ * Commit author name
+ */
+ author: string
+ /**
+ * Commit author email
+ */
+ email: string
+}
+
+export interface MergedRawCommit extends Omit {
+ filepaths: string[]
+}
+
+export interface GitChangelog
+ extends Omit {
+ /**
+ * The url of the commit
+ */
+ commitUrl?: string
+ /**
+ * release tag
+ */
+ tag?: string
+ /**
+ * The url of the release tag
+ */
+ tagUrl?: string
}
diff --git a/plugins/development/plugin-git/src/node/utils/checkGitRepo.ts b/plugins/development/plugin-git/src/node/utils/checkGitRepo.ts
index b33b39b0cd..091f51034d 100644
--- a/plugins/development/plugin-git/src/node/utils/checkGitRepo.ts
+++ b/plugins/development/plugin-git/src/node/utils/checkGitRepo.ts
@@ -1,4 +1,5 @@
import { execaCommandSync } from 'execa'
+import type { KnownGitProvider } from '../types.js'
/**
* Check if the git repo is valid
@@ -11,3 +12,44 @@ export const checkGitRepo = (cwd: string): boolean => {
return false
}
}
+
+const getRemoteUrl = (cwd: string): string => {
+ try {
+ const { stdout } = execaCommandSync('git remote get-url origin', { cwd })
+ return stdout
+ } catch {
+ try {
+ const { stdout } = execaCommandSync('git remote', { cwd })
+ const remote = stdout.split('\n')[0]?.trim()
+ if (remote) {
+ const { stdout: remoteUrl } = execaCommandSync(
+ `git remote get-url ${remote}`,
+ {
+ cwd,
+ },
+ )
+ return remoteUrl
+ }
+ return ''
+ } catch {
+ return ''
+ }
+ }
+}
+
+export const inferGitProvider = (cwd: string): KnownGitProvider | null => {
+ const remoteUrl = getRemoteUrl(cwd)
+ if (remoteUrl.includes('github.com')) {
+ return 'github'
+ }
+ if (remoteUrl.includes('gitlab.com')) {
+ return 'gitlab'
+ }
+ if (remoteUrl.includes('gitee.com')) {
+ return 'gitee'
+ }
+ if (remoteUrl.includes('bitbucket.org')) {
+ return 'bitbucket'
+ }
+ return null
+}
diff --git a/plugins/development/plugin-git/src/node/utils/getCommits.ts b/plugins/development/plugin-git/src/node/utils/getCommits.ts
new file mode 100644
index 0000000000..aa08662616
--- /dev/null
+++ b/plugins/development/plugin-git/src/node/utils/getCommits.ts
@@ -0,0 +1,87 @@
+import { execa } from 'execa'
+import type { MergedRawCommit, RawCommit } from '../types.js'
+
+const FORMAT = '%H|%an|%ae|%ad|%s|%d|%b'
+const SPLIT_CHAR = '[GIT_LOG_COMMIT_END]'
+const RE_SPLIT = /\[GIT_LOG_COMMIT_END\]$/
+
+/**
+ * Get raw commits
+ *
+ * ${commit_hash} ${author_name} ${author_email} ${author_date} ${subject} ${ref} ${body}
+ *
+ * @see {@link https://git-scm.com/docs/pretty-formats | documentation} for details.
+ */
+export const getRawCommits = async (
+ filepath: string,
+ cwd: string,
+): Promise => {
+ try {
+ const { stdout } = await execa(
+ 'git',
+ [
+ 'log',
+ '--max-count=-1',
+ `--format=${FORMAT}${SPLIT_CHAR}`,
+ '--date=unix',
+ '--follow',
+ '--',
+ filepath,
+ ],
+ { cwd },
+ )
+ return stdout.replace(RE_SPLIT, '').split(`${SPLIT_CHAR}\n`)
+ } catch {
+ return []
+ }
+}
+
+export const parseRawCommits = (
+ rawCommits: string[],
+ filepath: string,
+): RawCommit[] =>
+ rawCommits
+ .filter((commit) => !!commit)
+ .map((raw) => {
+ const [hash, author, email, date, message, refs, body] = raw
+ .split('|')
+ .map((v) => v.trim())
+ return {
+ filepath,
+ hash,
+ date: Number.parseInt(date, 10) * 1000,
+ message,
+ body,
+ refs,
+ author,
+ email,
+ }
+ })
+
+export const mergeRawCommits = (commits: RawCommit[]): MergedRawCommit[] => {
+ const commitMap = new Map()
+
+ commits.forEach(({ filepath, ...commit }) => {
+ const _commit = commitMap.get(commit.hash)
+ if (_commit) _commit.filepaths.push(filepath)
+ else commitMap.set(commit.hash, { filepaths: [filepath], ...commit })
+ })
+
+ const result = Array.from(commitMap.values())
+ return result
+}
+
+export const getCommits = async (
+ filepaths: string[],
+ cwd: string,
+): Promise => {
+ const commits = await Promise.all(
+ filepaths.map(async (filepath) => {
+ const rawCommits = await getRawCommits(filepath, cwd)
+ return parseRawCommits(rawCommits, filepath)
+ }),
+ )
+ return mergeRawCommits(commits.flat()).sort((a, b) =>
+ b.date - a.date > 0 ? 1 : -1,
+ )
+}
diff --git a/plugins/development/plugin-git/src/node/utils/getContributors.ts b/plugins/development/plugin-git/src/node/utils/getContributors.ts
deleted file mode 100644
index bb37d9e393..0000000000
--- a/plugins/development/plugin-git/src/node/utils/getContributors.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { execa } from 'execa'
-import type { GitContributor } from '../types.js'
-
-export const getContributors = async (
- filePaths: string[],
- cwd: string,
-): Promise => {
- const { stdout } = await execa(
- 'git',
- ['--no-pager', 'shortlog', '-nes', 'HEAD', '--', ...filePaths],
- {
- cwd,
- stdin: 'inherit',
- },
- )
-
- return stdout
- .split('\n')
- .map((item) => item.trim().match(/^(\d+)\t(.*) <(.*)>$/))
- .filter((item): item is RegExpMatchArray => item !== null)
- .map(([, commits, name, email]) => ({
- name,
- email,
- commits: Number.parseInt(commits, 10),
- }))
- .filter((item, index, self) => {
- // If one of the contributors is a "noreply" email address, and there's
- // already a contributor with the same name, it is very likely a duplicate,
- // so it can be removed.
- if (item.email.split('@')[1]?.match(/no-?reply/)) {
- const realIndex = self.findIndex((t) => t.name === item.name)
- if (realIndex !== index) {
- // Update the "real" contributor to also include the noreply's commits
- self[realIndex].commits += item.commits
- return false
- }
- return true
- }
- return true
- })
-}
diff --git a/plugins/development/plugin-git/src/node/utils/getCreatedTime.ts b/plugins/development/plugin-git/src/node/utils/getCreatedTime.ts
deleted file mode 100644
index 3fb7901980..0000000000
--- a/plugins/development/plugin-git/src/node/utils/getCreatedTime.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { execa } from 'execa'
-
-/**
- * Get unix timestamp in milliseconds of the first commit
- */
-export const getCreatedTime = async (
- filePaths: string[],
- cwd: string,
-): Promise => {
- const { stdout } = await execa(
- 'git',
- [
- '--no-pager',
- 'log',
- '--follow',
- '--diff-filter=A',
- '--format=%at',
- ...filePaths,
- ],
- {
- cwd,
- },
- )
-
- return (
- Math.min(...stdout.split('\n').map((item) => Number.parseInt(item, 10))) *
- 1000
- )
-}
diff --git a/plugins/development/plugin-git/src/node/utils/getUpdatedTime.ts b/plugins/development/plugin-git/src/node/utils/getUpdatedTime.ts
deleted file mode 100644
index e5b52317cb..0000000000
--- a/plugins/development/plugin-git/src/node/utils/getUpdatedTime.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { execa } from 'execa'
-
-/**
- * Get unix timestamp in milliseconds of the last commit
- */
-export const getUpdatedTime = async (
- filePaths: string[],
- cwd: string,
-): Promise => {
- const { stdout } = await execa(
- 'git',
- [
- '--no-pager',
- 'log',
- '--format=%at',
- // if there is only one file to be included, add `-1` option
- ...(filePaths.length > 1 ? [] : ['-1']),
- ...filePaths,
- ],
- {
- cwd,
- },
- )
-
- return (
- Math.max(...stdout.split('\n').map((item) => Number.parseInt(item, 10))) *
- 1000
- )
-}
diff --git a/plugins/development/plugin-git/src/node/utils/index.ts b/plugins/development/plugin-git/src/node/utils/index.ts
index 577d629102..be152ce046 100644
--- a/plugins/development/plugin-git/src/node/utils/index.ts
+++ b/plugins/development/plugin-git/src/node/utils/index.ts
@@ -1,4 +1,4 @@
export * from './checkGitRepo.js'
-export * from './getContributors.js'
-export * from './getCreatedTime.js'
-export * from './getUpdatedTime.js'
+export * from './getCommits.js'
+export * from './resolveContributors.js'
+export * from './resolveChangelog.js'
diff --git a/plugins/development/plugin-git/src/node/utils/resolveChangelog.ts b/plugins/development/plugin-git/src/node/utils/resolveChangelog.ts
new file mode 100644
index 0000000000..8156fae4f6
--- /dev/null
+++ b/plugins/development/plugin-git/src/node/utils/resolveChangelog.ts
@@ -0,0 +1,126 @@
+import type { App } from 'vuepress'
+import type {
+ ChangelogOptions,
+ ContributorConfig,
+ GitChangelog,
+ KnownGitProvider,
+ MergedRawCommit,
+} from '../types.js'
+import {
+ getContributorWithConfig,
+ getUserNameWithNoreplyEmail,
+} from './resolveContributors.js'
+
+interface Pattern {
+ issue?: string
+ tag?: string
+ commit?: string
+}
+
+const RE_ISSUE = /#(\d+)/g
+const RE_CLEAN_REFS = /[()]/g
+
+const patterns: Record = {
+ github: {
+ issue: ':repo/issues/:issue',
+ tag: ':repo/releases/tag/:tag',
+ commit: ':repo/commit/:hash',
+ },
+ gitlab: {
+ issue: ':repo/-/issues/:issue',
+ tag: ':repo/-/releases/:tag',
+ commit: ':repo/-/commit/:hash',
+ },
+ gitee: {
+ issue: ':repo/issues/:issue',
+ tag: ':repo/releases/tag/:tag',
+ commit: ':repo/commit/:hash',
+ },
+ bitbucket: {
+ issue: ':repo/issues/:issue',
+ tag: ':repo/src/:hash',
+ commit: ':repo/commits/:hash',
+ },
+}
+
+const getPattern = (
+ { commitUrlPattern, issueUrlPattern, tagUrlPattern }: ChangelogOptions,
+ provider: KnownGitProvider | null,
+): Pattern => {
+ const fallback = provider ? patterns[provider] : {}
+
+ return {
+ commit: commitUrlPattern ?? fallback.commit,
+ issue: issueUrlPattern ?? fallback.issue,
+ tag: tagUrlPattern ?? fallback.tag,
+ }
+}
+
+const parseTagName = (refs: string): string | undefined => {
+ if (!refs) return
+
+ const tags = refs
+ .replace(RE_CLEAN_REFS, '')
+ .split(',')
+ .map((tag) => tag.trim())
+
+ return tags[0]?.replace('tag:', '').trim()
+}
+
+export const resolveChangelog = (
+ app: App,
+ commits: MergedRawCommit[],
+ options: ChangelogOptions,
+ gitProvider: KnownGitProvider | null,
+ contributors: ContributorConfig[],
+): GitChangelog[] => {
+ const pattern = getPattern(options, gitProvider)
+ const repo = options.repoUrl
+ const result: GitChangelog[] = []
+
+ const sliceCommits = options.maxCount
+ ? commits.slice(0, options.maxCount)
+ : commits
+
+ for (const commit of sliceCommits) {
+ const { hash, message, date, author, email, refs } = commit
+ const tag = parseTagName(refs)
+ const contributor = getContributorWithConfig(
+ contributors,
+ getUserNameWithNoreplyEmail(email) ?? author,
+ )
+ const resolved: GitChangelog = {
+ hash,
+ date,
+ email,
+ author: contributor?.name ?? contributor?.username ?? author,
+ message: app.markdown.renderInline(message),
+ }
+
+ if (pattern.issue && repo) {
+ resolved.message = resolved.message.replace(
+ RE_ISSUE,
+ (matched, issue: string) => {
+ const url = pattern
+ .issue!.replace(':issue', issue)
+ .replace(':repo', repo)
+ return `${matched}`
+ },
+ )
+ }
+
+ if (pattern.commit && repo)
+ resolved.commitUrl = pattern.commit
+ .replace(':hash', hash)
+ .replace(':repo', repo)
+
+ if (tag) resolved.tag = tag
+
+ if (pattern.tag && repo && tag)
+ resolved.tagUrl = pattern.tag.replace(':tag', tag).replace(':repo', repo)
+
+ result.push(resolved)
+ }
+
+ return result
+}
diff --git a/plugins/development/plugin-git/src/node/utils/resolveContributors.ts b/plugins/development/plugin-git/src/node/utils/resolveContributors.ts
new file mode 100644
index 0000000000..1574888ce2
--- /dev/null
+++ b/plugins/development/plugin-git/src/node/utils/resolveContributors.ts
@@ -0,0 +1,142 @@
+import { webcrypto } from 'node:crypto'
+import type {
+ ContributorConfig,
+ ContributorsOptions,
+ GitContributor,
+ KnownGitProvider,
+ MergedRawCommit,
+} from '../types.js'
+
+export const getUserNameWithNoreplyEmail = (
+ email: string,
+): string | undefined => {
+ if (email.endsWith('@users.noreply.github.com')) {
+ return email.replace('@users.noreply.github.com', '').split('+')[1]
+ }
+ return undefined
+}
+
+const toArray = (value?: string[] | string): string[] => {
+ if (!value) return []
+ return Array.isArray(value) ? value : [value]
+}
+
+export const digestSHA256 = async (message: string): Promise => {
+ const encoded = new TextEncoder().encode(message)
+ const buffer = await webcrypto.subtle.digest('SHA-256', encoded)
+ return Array.from(new Uint8Array(buffer))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('')
+}
+
+export const getContributorWithConfig = (
+ list: ContributorConfig[],
+ name: string,
+): ContributorConfig | undefined => {
+ return list.find(
+ (item) => item.username === name || toArray(item.alias).includes(name),
+ )
+}
+
+export const getRawContributors = async (
+ commits: MergedRawCommit[],
+ options: ContributorsOptions,
+ gitProvider: KnownGitProvider | null = null,
+): Promise => {
+ const contributors = new Map()
+
+ for (const commit of commits) {
+ const { author, email } = commit
+ const config = getContributorWithConfig(
+ options.list ?? [],
+ getUserNameWithNoreplyEmail(email) ?? author,
+ )
+ const username = config?.username ?? author
+ const name = config?.name ?? username
+
+ const contributor = contributors.get(name + email)
+ if (contributor) {
+ contributor.commits++
+ } else {
+ const item: GitContributor = {
+ name,
+ email,
+ commits: 1,
+ }
+
+ if (options.avatar)
+ item.avatar =
+ config?.avatar ??
+ (gitProvider === 'github'
+ ? `https://avatars.githubusercontent.com/${username}?v=4`
+ : `https://gravatar.com/avatar/${await digestSHA256(email || username)}?d=retro`)
+
+ const url =
+ (config?.url ?? gitProvider === 'github')
+ ? `https://github.com/${username}`
+ : undefined
+ if (url) item.url = url
+
+ contributors.set(name + email, item)
+ }
+ }
+
+ return Array.from(contributors.values()).filter((item, index, self) => {
+ // If one of the contributors is a "noreply" email address, and there's
+ // already a contributor with the same name, it is very likely a duplicate,
+ // so it can be removed.
+ if (item.email.split('@')[1]?.match(/no-?reply/)) {
+ const realIndex = self.findIndex((t) => t.name === item.name)
+ if (realIndex !== index) {
+ // Update the "real" contributor to also include the noreply's commits
+ self[realIndex].commits += item.commits
+ return false
+ }
+ return true
+ }
+ return true
+ })
+}
+
+export const resolveContributors = async (
+ commits: MergedRawCommit[],
+ options: ContributorsOptions = {},
+ gitProvider: KnownGitProvider | null = null,
+ extraContributors?: string[],
+): Promise => {
+ let contributors = await getRawContributors(commits, options, gitProvider)
+
+ if (options.list?.length && extraContributors?.length) {
+ for (const extraContributor of extraContributors) {
+ if (!contributors.some((item) => item.name === extraContributor)) {
+ const config = getContributorWithConfig(options.list, extraContributor)
+ if (!config) continue
+
+ const item: GitContributor = {
+ name: config.name ?? extraContributor,
+ email: '',
+ commits: 0,
+ }
+
+ if (options.avatar)
+ item.avatar =
+ config.avatar ??
+ (gitProvider === 'github'
+ ? `https://avatars.githubusercontent.com/${config.username}?v=4`
+ : `https://gravatar.com/avatar/${await digestSHA256(config.username)}?d=retro`)
+
+ const url =
+ (config.url ?? gitProvider === 'github')
+ ? `https://github.com/${config.username}`
+ : undefined
+ if (url) item.url = url
+
+ contributors.push(item)
+ }
+ }
+ }
+
+ if (options.transform) contributors = await options.transform(contributors)
+
+ return contributors
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f084c075ab..ce9b8664ef 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -377,8 +377,8 @@ importers:
plugins/development/plugin-git:
dependencies:
execa:
- specifier: ^9.4.1
- version: 9.4.1
+ specifier: ^9.5.1
+ version: 9.5.1
vuepress:
specifier: 2.0.0-rc.18
version: 2.0.0-rc.18(@vuepress/bundler-vite@2.0.0-rc.18(@types/node@22.7.9)(jiti@1.21.6)(lightningcss@1.27.0)(sass-embedded@1.80.4)(sass@1.80.4)(terser@5.36.0)(tsx@4.19.1)(typescript@5.6.3)(yaml@2.4.5))(@vuepress/bundler-webpack@2.0.0-rc.18(esbuild@0.23.1)(typescript@5.6.3))(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3))
@@ -2803,35 +2803,30 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@parcel/watcher-linux-arm64-glibc@2.4.1':
resolution: {integrity: sha512-BJ7mH985OADVLpbrzCLgrJ3TOpiZggE9FMblfO65PlOCdG++xJpKUJ0Aol74ZUIYfb8WsRlUdgrZxKkz3zXWYA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.4.1':
resolution: {integrity: sha512-p4Xb7JGq3MLgAfYhslU2SjoV9G0kI0Xry0kuxeG/41UfpjHGOhv7UoUDAz/jb1u2elbhazy4rRBL8PegPJFBhA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.4.1':
resolution: {integrity: sha512-s9O3fByZ/2pyYDPoLM6zt92yu6P4E39a03zvO0qCHOTjxmt3GHRMLuRZEWhWLASTMSrrnVNWdVI/+pUElJBBBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.4.1':
resolution: {integrity: sha512-L2nZTYR1myLNST0O632g0Dx9LyMNHrn6TOt76sYxWLdff3cB22/GZX2UPtJnaqQPdCRoszoY5rcOj4oMTtp5fQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@parcel/watcher-win32-arm64@2.4.1':
resolution: {integrity: sha512-Uq2BPp5GWhrq/lcuItCHoqxjULU1QYEcyjSO5jqqOK8RNFDBQnenMMx4gAl3v8GiWa59E9+uDM7yZ6LxwUIfRg==}
@@ -2946,55 +2941,46 @@ packages:
resolution: {integrity: sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.24.0':
resolution: {integrity: sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==}
cpu: [arm]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.24.0':
resolution: {integrity: sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.24.0':
resolution: {integrity: sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-powerpc64le-gnu@4.24.0':
resolution: {integrity: sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.24.0':
resolution: {integrity: sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-s390x-gnu@4.24.0':
resolution: {integrity: sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.24.0':
resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.24.0':
resolution: {integrity: sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.24.0':
resolution: {integrity: sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==}
@@ -4690,6 +4676,10 @@ packages:
resolution: {integrity: sha512-5eo/BRqZm3GYce+1jqX/tJ7duA2AnE39i88fuedNFUV8XxGxUpF3aWkBRfbUcjV49gCkvS/pzc0YrCPhaIewdg==}
engines: {node: ^18.19.0 || >=20.5.0}
+ execa@9.5.1:
+ resolution: {integrity: sha512-QY5PPtSonnGwhhHDNI7+3RvY285c7iuJFFB+lU+oEzMY/gEGJ808owqJsrr8Otd1E/x07po1LkUBmdAc5duPAg==}
+ engines: {node: ^18.19.0 || >=20.5.0}
+
exponential-backoff@3.1.1:
resolution: {integrity: sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==}
@@ -5689,28 +5679,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.27.0:
resolution: {integrity: sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.27.0:
resolution: {integrity: sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.27.0:
resolution: {integrity: sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.27.0:
resolution: {integrity: sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==}
@@ -12585,6 +12571,21 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.1.1
+ execa@9.5.1:
+ dependencies:
+ '@sindresorhus/merge-streams': 4.0.0
+ cross-spawn: 7.0.3
+ figures: 6.1.0
+ get-stream: 9.0.1
+ human-signals: 8.0.0
+ is-plain-obj: 4.1.0
+ is-stream: 4.0.1
+ npm-run-path: 6.0.0
+ pretty-ms: 9.1.0
+ signal-exit: 4.1.0
+ strip-final-newline: 4.0.0
+ yoctocolors: 2.1.1
+
exponential-backoff@3.1.1: {}
express@4.21.1: