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: