@nolebase/vitepress-plugin-git-changelog
Version:
A VitePress plugin that adds a changelog fetched from git to your documentation.
450 lines (440 loc) • 17.2 kB
JavaScript
;
const node_path = require('node:path');
const node_process = require('node:process');
const colorette = require('colorette');
const execa = require('execa');
const globby = require('globby');
const vite = require('vite');
const esToolkit = require('es-toolkit');
const uncrypto = require('uncrypto');
const GrayMatter = require('gray-matter');
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; }
const GrayMatter__default = /*#__PURE__*/_interopDefaultCompat(GrayMatter);
function pathEquals(path, equals) {
return vite.normalizePath(path) === vite.normalizePath(equals);
}
function pathStartsWith(path, startsWith) {
return vite.normalizePath(path).startsWith(vite.normalizePath(startsWith));
}
function pathEndsWith(path, startsWith) {
return vite.normalizePath(path).endsWith(vite.normalizePath(startsWith));
}
function createHelpers(root, id) {
const relativeId = node_path.relative(root, id);
return {
pathStartsWith,
pathEquals,
pathEndsWith,
idEndsWith(endsWith) {
return pathEndsWith(relativeId, endsWith);
},
idEquals(equals) {
return pathEquals(relativeId, equals);
},
idStartsWith(startsWith) {
return pathStartsWith(relativeId, startsWith);
}
};
}
async function digestStringAsSHA256(message) {
const msgUint8 = new TextEncoder().encode(message);
const hashBuffer = await uncrypto.subtle.digest("SHA-256", msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
return hashHex;
}
const defaultCommitURLHandler = (commit) => `${commit.repo_url}/commit/${commit.hash}`;
const defaultReleaseTagURLHandler = (commit) => `${commit.repo_url}/releases/tag/${commit.tag}`;
const defaultReleaseTagsURLHandler = (commit) => commit.tags?.map((tag) => `${commit.repo_url}/releases/tag/${tag}`);
async function returnOrResolvePromise(val) {
if (!(val instanceof Promise))
return val;
return await val;
}
async function rewritePathsByPatterns(commit, path, patterns) {
if (typeof patterns === "undefined" || patterns === null)
return path;
if ("handler" in patterns && typeof patterns.handler === "function") {
const resolvedPath = await returnOrResolvePromise(patterns.handler(commit, path));
if (!resolvedPath)
return path;
return resolvedPath;
}
return path;
}
function rewritePathsByRewritingExtension(from, to) {
return (_, path) => {
const ext = node_path.extname(path);
if (ext !== from)
return path;
return path.replace(new RegExp(`${from}$`), to);
};
}
function parseGitLogRefsAsTags(refs) {
if (!refs)
return [];
const refsArray = refs.split(", ").map((ref) => ref.trim());
const tags = refsArray.filter((ref) => ref.startsWith("tag: "));
if (!tags)
return [];
return tags.map((tag) => tag.replace("tag: ", "").trim());
}
function getRelativePath(file, srcDir, cwd) {
cwd = vite.normalizePath(cwd);
return file.replace(srcDir, "").replace(cwd, "").replace(/^\//, "");
}
async function getRawCommitLogs(file, maxGitLogCount) {
const fileDir = node_path.dirname(file);
const fileName = node_path.basename(file);
const format = "%H|%an|%ae|%ad|%s|%d|%b";
const { stdout } = await execa.execa("git", ["log", `--max-count=${maxGitLogCount ?? -1}`, `--format=${format}[GIT_LOG_COMMIT_END]`, "--follow", "--", fileName], { cwd: fileDir });
return stdout.replace(/\[GIT_LOG_COMMIT_END\]$/, "").split("[GIT_LOG_COMMIT_END]\n");
}
function parseRawCommitLogs(path, rawLogs) {
return rawLogs.filter((log) => !!log).map((raw) => {
const [hash, author_name, author_email, date, message, refs, body] = raw.split("|").map((v) => v.trim());
return {
path,
hash,
date,
message,
body,
refs,
author_name,
author_email
};
});
}
function mergeRawCommits(rawCommits) {
const commitMap = /* @__PURE__ */ new Map();
rawCommits.forEach((commit) => {
const _commit = commitMap.get(commit.hash);
if (_commit)
_commit.paths.push(commit.path);
else
commitMap.set(commit.hash, { paths: [commit.path], ...esToolkit.omit(commit, ["path"]) });
});
return Array.from(commitMap.values());
}
async function parseCommits(rawCommits, getRepoURL, getCommitURL, getReleaseTagURL, getReleaseTagsURL, mapContributors, optsRewritePathsBy) {
const allAuthors = /* @__PURE__ */ new Map();
const resolvedCommits = await Promise.all(rawCommits.map(async (rawCommit) => {
const { paths, hash, date, refs, message } = rawCommit;
const resolvedCommit = {
paths,
hash,
date_timestamp: new Date(date).getTime(),
message,
authors: []
};
if (typeof optsRewritePathsBy !== "undefined")
await Promise.all(rawCommit.paths.map(async (p) => await rewritePathsByPatterns(resolvedCommit, p, optsRewritePathsBy)));
resolvedCommit.repo_url = await returnOrResolvePromise(getRepoURL(resolvedCommit)) ?? "https://github.com/example/example";
resolvedCommit.hash_url = await returnOrResolvePromise(getCommitURL(resolvedCommit)) ?? defaultCommitURLHandler(resolvedCommit);
const tags = parseGitLogRefsAsTags(refs?.replace(/[()]/g, ""));
if (tags && tags.length > 0) {
resolvedCommit.tags = tags;
resolvedCommit.tag = resolvedCommit.tags?.[0] || void 0;
resolvedCommit.release_tag_url = await returnOrResolvePromise(getReleaseTagURL(resolvedCommit)) ?? defaultReleaseTagURLHandler(resolvedCommit);
resolvedCommit.release_tags_url = await returnOrResolvePromise(getReleaseTagsURL(resolvedCommit)) ?? defaultReleaseTagsURLHandler(resolvedCommit);
}
const authors = await parseCommitAuthors(rawCommit, mapContributors);
authors.forEach((a) => allAuthors.set(a.name, esToolkit.omit(a, ["email"])));
resolvedCommit.authors = authors.map((a) => a.name);
return resolvedCommit;
}));
return { commits: resolvedCommits, authors: [...allAuthors.values()] };
}
async function parseCommitAuthors(commit, mapContributors) {
const { author_name, author_email, body } = commit;
const commitAuthor = {
name: author_name,
email: author_email
};
const coAuthors = getCoAuthors(body);
return await Promise.all([commitAuthor, ...coAuthors].filter((v) => !(v.name.match(/\[bot\]/i) || v.email?.match(/\[bot\]/i))).map(async (author) => {
const targetCreatorByName = findMapAuthorByName(mapContributors, author.name);
if (targetCreatorByName) {
author.name = targetCreatorByName.name ?? author.name;
author.i18n = findMapAuthorI18n(targetCreatorByName);
author.url = findMapAuthorLink(targetCreatorByName);
author.avatarUrl = await newAvatarForAuthor(targetCreatorByName, author.email);
return author;
}
const targetCreatorByEmail = author.email && findMapAuthorByEmail(mapContributors, author.email);
if (targetCreatorByEmail) {
author.name = targetCreatorByEmail.name ?? author.name;
author.i18n = findMapAuthorI18n(targetCreatorByEmail);
author.url = findMapAuthorLink(targetCreatorByEmail);
author.avatarUrl = await newAvatarForAuthor(targetCreatorByEmail, author.email);
return author;
}
const targetCreatorByGitHub = findMapAuthorByGitHub(mapContributors, author.name, author.email);
if (targetCreatorByGitHub) {
author.name = targetCreatorByGitHub.name ?? author.name;
author.i18n = findMapAuthorI18n(targetCreatorByGitHub);
author.url = findMapAuthorLink(targetCreatorByGitHub);
author.avatarUrl = await newAvatarForAuthor(targetCreatorByGitHub, author.email);
return author;
}
author.avatarUrl = await newAvatarForAuthor(void 0, author.email);
return author;
}));
}
const multipleAuthorsRegex = /^ *Co-authored-by: ?([^<]*)<([^>]*)> */gim;
function getCoAuthors(body) {
if (!body)
return [];
return [...body.matchAll(multipleAuthorsRegex)].map((result) => {
const [, name, email] = result;
return {
name: name.trim(),
email: email.trim()
};
}).filter((v) => !!v);
}
function findMapAuthorByName(mapContributors, author_name) {
return mapContributors?.find((item) => {
const res = item.mapByNameAliases && Array.isArray(item.mapByNameAliases) && item.mapByNameAliases.includes(author_name) || item.name === author_name || item.username === author_name;
if (res)
return true;
return item.nameAliases && Array.isArray(item.nameAliases) && item.nameAliases.includes(author_name);
});
}
function findMapAuthorByEmail(mapContributors, author_email) {
return mapContributors?.find((item) => {
const res = item.mapByEmailAliases && Array.isArray(item.mapByEmailAliases) && item.mapByEmailAliases.includes(author_email);
if (res)
return true;
return item.emailAliases && Array.isArray(item.emailAliases) && item.emailAliases.includes(author_email);
});
}
function findMapAuthorByGitHub(mapContributors, author_name, author_email) {
const github = getGitHubUserNameFromNoreplyAddress(author_email);
if (github && github.userName) {
const mappedByName = findMapAuthorByName(mapContributors, github.userName);
if (mappedByName && mappedByName.username) {
mappedByName.username ||= github.userName;
return mappedByName;
}
return {
name: author_name,
username: github.userName
};
}
}
function findMapAuthorLink(creator) {
if (!creator.links && !!creator.username)
return `https://github.com/${creator.username}`;
if (typeof creator.links === "string" && !!creator.links)
return creator.links;
if (!Array.isArray(creator.links))
return;
const priority = ["github", "twitter"];
for (const p of priority) {
const matchedEntry = creator.links?.find((l) => l.type === p);
if (matchedEntry)
return matchedEntry.link;
}
return creator.links?.[0]?.link;
}
function findMapAuthorI18n(mappedAuthor) {
if (mappedAuthor && mappedAuthor.i18n) {
return mappedAuthor.i18n;
}
return void 0;
}
function getGitHubUserNameFromNoreplyAddress(email) {
const match = email.match(/^(?:(?<userId>\d+)\+)?(?<userName>[a-zA-Z\d-]{1,39})@users.noreply.github.com$/);
if (!match || !match.groups)
return void 0;
const { userName, userId } = match.groups;
return { userId, userName };
}
async function newAvatarForAuthor(mappedAuthor, email) {
if (mappedAuthor) {
if (mappedAuthor.avatar)
return mappedAuthor.avatar;
if (mappedAuthor.username)
return `https://github.com/${mappedAuthor.username}.png`;
}
return `https://gravatar.com/avatar/${await digestStringAsSHA256(email)}?d=retro`;
}
const VirtualModuleID = "virtual:nolebase-git-changelog";
const ResolvedVirtualModuleId = `\0${VirtualModuleID}`;
const logModulePrefix = `${colorette.cyan(`@nolebase/vitepress-plugin-git-changelog`)}${colorette.gray(":")}`;
const logger = console;
function GitChangelog(options = {}) {
if (!options)
options = {};
const {
cwd = node_process.cwd(),
maxGitLogCount,
include = ["**/*.md", "!node_modules"],
mapAuthors,
repoURL = "https://github.com/example/example",
getReleaseTagURL = defaultReleaseTagURLHandler,
getReleaseTagsURL = defaultReleaseTagsURLHandler,
getCommitURL = defaultCommitURLHandler,
rewritePathsBy
} = options;
const getRepoURL = typeof repoURL === "function" ? repoURL : () => repoURL;
const changelog = { commits: [], authors: [] };
const hotModuleReloadCachedCommits = /* @__PURE__ */ new Map();
let srcDir = "";
let config;
const commitFromPath = async (paths) => {
const rawCommits = (await Promise.all(paths.map(async (path) => {
const rawLogs = await getRawCommitLogs(path, maxGitLogCount);
const relativePath = getRelativePath(path, srcDir, cwd);
const rawCommits2 = parseRawCommitLogs(relativePath, rawLogs);
return rawCommits2;
}))).flat();
const mergedRawCommits = mergeRawCommits(rawCommits);
const resolvedCommits = parseCommits(
mergedRawCommits,
getRepoURL,
getCommitURL,
getReleaseTagURL,
getReleaseTagsURL,
mapAuthors,
rewritePathsBy
);
return resolvedCommits;
};
return {
name: "@nolebase/vitepress-plugin-git-changelog",
config: () => ({
optimizeDeps: {
exclude: [
"@nolebase/vitepress-plugin-git-changelog/client"
]
},
ssr: {
noExternal: [
"@nolebase/vitepress-plugin-git-changelog",
// @nolebase/ui required here as noExternal to avoid the following error:
// TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".vue" for ...
// Read more here: https://github.com/vuejs/vitepress/issues/2915
// And here: https://stackblitz.com/edit/vite-gjz9zf?file=docs%2F.vitepress%2Fconfig.ts
"@nolebase/ui"
]
}
}),
configResolved(_config) {
config = _config;
srcDir = config.vitepress.srcDir;
},
async buildStart() {
if (config.command !== "build")
return;
const startsAt = Date.now();
logger.info(`${logModulePrefix} Prepare to gather git logs...`);
if (changelog.commits.length > 0)
return;
await execa.execa("git", ["config", "--local", "core.quotepath", "false"]);
const paths = await globby.globby(include, {
gitignore: true,
cwd,
absolute: true
});
Object.assign(changelog, await commitFromPath(paths));
const elapsed = Date.now() - startsAt;
logger.info(`${logModulePrefix} Done. ${colorette.gray(`(${elapsed}ms)`)}`);
},
resolveId(source) {
if (source.startsWith(VirtualModuleID))
return `\0${source}`;
},
load(id) {
if (!id.startsWith(ResolvedVirtualModuleId))
return null;
return `export default ${JSON.stringify(changelog, null, config.isProduction ? 0 : 2)}`;
},
configureServer(server) {
compatibleConfigureServer(server, (_, env) => {
env.hot.on("nolebase-git-changelog:client-mounted", async (data) => {
if (!data || typeof data !== "object")
return;
if (!("page" in data && "filePath" in data.page))
return;
let result = hotModuleReloadCachedCommits.get(data.page.filePath);
if (!result) {
const path = vite.normalizePath(node_path.join(srcDir, data.page.filePath));
result = await commitFromPath([path]);
hotModuleReloadCachedCommits.set(data.page.filePath, result);
}
Object.assign(changelog, result);
if (!result.commits.length)
return;
const virtualModule = env.moduleGraph.getModuleById(ResolvedVirtualModuleId);
if (!virtualModule)
return;
env.moduleGraph.invalidateModule(virtualModule);
env.hot.send({
type: "custom",
event: "nolebase-git-changelog:updated",
data: changelog
});
});
});
}
};
}
function compatibleConfigureServer(server, registerHandler) {
if ("environments" in server && typeof server.environments === "object" && server.environments != null) {
Object.entries(server.environments).forEach(([name, env]) => registerHandler(name, env));
} else {
registerHandler("server", server);
}
}
function GitChangelogMarkdownSection(options) {
const {
excludes = ["index.md"],
exclude = () => false
} = options ?? {};
let root = "";
return {
name: "@nolebase/vitepress-plugin-git-changelog-markdown-section",
// May set to 'pre' since end user may use vitepress wrapped vite plugin to
// specify the plugins, which may cause this plugin to be executed after
// vitepress or the other markdown processing plugins.
enforce: "pre",
configResolved(config) {
root = config.root ?? "";
},
transform(code, id) {
if (!id.endsWith(".md"))
return null;
const helpers = createHelpers(root, id);
if (excludes.includes(node_path.relative(root, id)))
return null;
if (exclude(id, { helpers }))
return null;
const parsedMarkdownContent = GrayMatter__default(code);
if ("nolebase" in parsedMarkdownContent.data && "gitChangelog" in parsedMarkdownContent.data.nolebase && !parsedMarkdownContent.data.nolebase.gitChangelog)
return null;
if ("gitChangelog" in parsedMarkdownContent.data && !parsedMarkdownContent.data.gitChangelog)
return null;
if (!options?.sections?.disableContributors)
code = TemplateContributors(code);
if (!options?.sections?.disableChangelog)
code = TemplateChangelog(code);
return code;
}
};
}
function TemplateContributors(code) {
return `${code}
<NolebaseGitContributors />
`;
}
function TemplateChangelog(code) {
return `${code}
<NolebaseGitChangelog />
`;
}
exports.GitChangelog = GitChangelog;
exports.GitChangelogMarkdownSection = GitChangelogMarkdownSection;
exports.rewritePathsByRewritingExtension = rewritePathsByRewritingExtension;
//# sourceMappingURL=index.cjs.map