github-action-readme-generator
Version:
The docs generator for GitHub Actions. Auto-syncs action.yml to README.md with 8 sections: inputs, outputs, usage, badges, branding & more. Works as CLI or GitHub Action.
488 lines • 17.9 kB
JavaScript
import { execSync } from 'node:child_process';
import { accessSync, readFileSync } from 'node:fs';
import * as path from 'node:path';
import LogTask from './logtask/index.js';
import { unicodeWordMatch } from './unicode-word-match.js';
import { notEmpty } from './util.js';
/**
* Returns the input value if it is not empty, otherwise returns undefined.
* @param value - The input value to check.
* @returns The input value if it is not empty, otherwise undefined.
*/
export function undefinedOnEmpty(value) {
if (!value || value === '') {
return undefined;
}
return value;
}
/**
* Returns the basename of the given path.
* @param pathStr - The path to extract the basename from.
* @returns The basename of the path.
*/
export function basename(pathStr) {
if (!pathStr) {
return undefined;
}
const log = new LogTask('basename');
const result = path.basename(pathStr);
log.debug(`Basename passed ${pathStr} and returns ${result}`);
return result;
}
/**
* Removes the "refs/heads/" or "refs/tags/" prefix from the given path.
*
* @param pathStr - The path to remove the prefix from
* @returns The path without the prefix, or null if path is empty
*/
export function stripRefs(pathStr) {
if (!pathStr) {
return null;
}
const log = new LogTask('stripRefs');
const result = pathStr.replace('refs/heads/', '').replace('refs/tags/', '');
log.debug(`stripRefs passed ${pathStr} and returns ${result}`);
return result;
}
/**
* Converts the given text to title case.
* @param text - The text to convert.
* @returns The text converted to title case.
* @throws {TypeError} If the input is not a string.
*/
export function titlecase(text) {
if (!text) {
return undefined;
}
if (typeof text !== 'string') {
throw new TypeError(`Invalid argument type provided to titlecase(): ${typeof text}`);
}
return text.replaceAll(unicodeWordMatch, (txt) => txt[0] ? txt[0].toUpperCase() + txt.slice(1).toLowerCase() : txt);
}
/**
* Parses the given text and converts it to title case, replacing underscores and dashes with spaces.
* @param text - The text to parse and convert.
* @returns The parsed text converted to title case.
*/
export function prefixParser(text) {
if (!text) {
return undefined;
}
if (typeof text !== 'string') {
throw new TypeError(`Invalid argument type provided to prefixParser(): ${typeof text}`);
}
return titlecase(text.replace(/[_-]+/, ' '));
}
/**
* Wraps the given text into multiple lines with a maximum width of 80 characters.
* @param text - The text to wrap.
* @param content - The array to store the wrapped lines.
* @param prepend - The string to prepend to each wrapped line.
* @returns The array of wrapped lines.
*/
export function wrapText(text, content, prepend = '') {
// Constrain the width of the description
if (!text) {
return content;
}
const width = 80;
let description = text
.trim()
.replaceAll('\r\n', '\n') // Convert CR to LF
.replaceAll(/ +/g, ' ') // Squash consecutive spaces
.replaceAll(' \n', '\n'); // Squash space followed by newline
while (description) {
// Longer than width? Find a space to break apart
let segment;
if (description.length > width) {
segment = description.slice(0, Math.max(0, width + 1));
while (!segment.endsWith(' ') && !segment.endsWith('\n') && segment) {
segment = segment.slice(0, Math.max(0, segment.length - 1));
}
// Trimmed too much?
if (segment.length < width * 0.67) {
segment = description;
}
}
else {
segment = description;
}
// Check for newline
const newlineIndex = segment.indexOf('\n');
if (newlineIndex >= 0) {
segment = segment.slice(0, Math.max(0, newlineIndex + 1));
}
content.push(`${prepend}${segment}`.trimEnd());
// Remaining
description = description.slice(segment.length);
}
return content;
}
export function readFile(filename) {
try {
return readFileSync(filename, 'utf8');
}
catch (error) {
throw new Error(`Cannot read file ${filename}: ${error}`);
}
}
export function repoObjFromRepoName(repository, log, from) {
if (notEmpty(repository)) {
const [owner, repo] = repository.split('/');
if (owner && repo) {
log.debug(`repoObjFromRepoName using ${from} and returns ${JSON.stringify({ owner, repo })}`);
return { owner, repo };
}
}
return undefined;
}
// Pattern to match GitHub remote URLs in .git/config
// Handles both HTTPS (github.com/) and SSH (github.com:) formats
// Captures the repo name with or without .git suffix - suffix is stripped in code
export const remoteGitUrlPattern = /url\s*=\s*.*github\.com[/:](?<owner>[^/\s]+)\/(?<repo>[^\s]+)/;
/**
* Finds the repository information from the input, context, environment variables, or git configuration.
* @param inputRepo - The input repository string.
* @param context - The GitHub context object.
* @param baseDir - Optional base directory to look for .git/config (defaults to CWD).
* @returns The repository information (owner and repo) or null if not found.
*/
export function repositoryFinder(inputRepo, context, baseDir) {
const log = new LogTask('repositoryFinder');
/**
* Attempt to get git user and repo from input
*/
const repoObj = repoObjFromRepoName(inputRepo, log, 'inputRepo');
if (repoObj) {
return repoObj;
}
/**
* When baseDir is provided, prioritize .git/config from that directory
* This is critical for external repos where GITHUB_REPOSITORY points to
* the workflow repo, not the target repo being documented
*/
if (baseDir) {
try {
const gitConfigPath = path.join(baseDir, '.git', 'config');
const fileContent = readFile(gitConfigPath);
log.debug(`Reading git config from: ${gitConfigPath}`);
const results = remoteGitUrlPattern.exec(fileContent);
if (results?.groups?.owner && results?.groups?.repo) {
// Strip .git suffix if present (actions/checkout may or may not include it)
const repo = results.groups.repo.replace(/\.git$/, '');
log.debug(`repositoryFinder using '${gitConfigPath}' and returns ${JSON.stringify({ owner: results.groups.owner, repo })}`);
return {
owner: results.groups.owner,
repo,
};
}
else {
log.debug(`No remote URL found in ${gitConfigPath}`);
}
}
catch (error) {
log.debug(`Couldn't read .git/config from baseDir ${baseDir}: ${error}`);
// Fall through to other methods
}
}
/**
* Attempt to get git user and repo from GitHub context,
* which includes checking for GITHUB_REPOSITORY environment variable
*/
if (context) {
try {
const result = { ...context.repo };
if (result.owner && result.repo) {
log.debug(`repositoryFinder using GitHub context and returns ${JSON.stringify(result)}`);
return result;
}
}
catch (error) {
log.debug(`repositoryFinder using GitHub context gives error ${JSON.stringify(error)}`);
}
}
/**
* Fallback: Try to parse GITHUB_REPOSITORY environment variable directly
* This handles cases where the Context class doesn't pick up the value
*/
const githubRepo = process.env.GITHUB_REPOSITORY;
if (githubRepo) {
const repoFromEnv = repoObjFromRepoName(githubRepo, log, 'GITHUB_REPOSITORY env');
if (repoFromEnv) {
return repoFromEnv;
}
}
/**
* Last resort: Attempt to get git user and repo from .git/config in CWD
*/
try {
const fileContent = readFile('.git/config');
log.debug('Reading git config from: .git/config');
const results = remoteGitUrlPattern.exec(fileContent);
if (results?.groups?.owner && results?.groups?.repo) {
// Strip .git suffix if present
const repo = results.groups.repo.replace(/\.git$/, '');
log.debug(`repositoryFinder using '.git/config' and returns ${JSON.stringify({ owner: results.groups.owner, repo })}`);
return {
owner: results.groups.owner,
repo,
};
}
else {
log.debug('No remote URL found in .git/config');
}
}
catch (error) {
// can't find it
log.error(`Couldn't retrieve owner or repo in .git/config file: ${error}`);
}
throw new Error('No owner or repo found');
}
/**
* Returns the default branch of the git repository.
* @returns The default branch.
*/
/**
* Gets the default branch for the Git repository.
*
* @returns The name of the default branch.
*/
export function getDefaultGitBranch() {
let result;
try {
// Run git command to get default branch
result = execSync('git symbolic-ref HEAD | sed s@^refs/heads/@@');
}
catch (error) {
// If command fails, try alternative for MacOS
if (error) {
try {
result = execSync("git remote set-head origin -a;git remote show origin | head 50 sed -n 's/^.*default branch \\(.*\\)/\\1/p'");
}
catch {
result = execSync("git remote set-head origin -a;git remote show origin | sed -n 's/^s*HEAD branch: \\(.*\\)/\\1/p'");
}
}
}
return result?.toString().trim() ?? '';
}
/**
* Formats the given value as a column header.
* @param value - The value to format.
* @returns The formatted column header.
*/
export function columnHeader(value) {
if (!value) {
return '';
}
let text = value.replaceAll(/\*\*(.*?)\*\*/g, '$1');
// Remove italic formatting: *italic*
text = text.replaceAll(/\*(.*?)\*/g, '$1');
// Remove strikethrough formatting: ~~strikethrough~~
text = text.replaceAll(/~~(.*?)~~/g, '$1');
const normalisedHeader = titlecase(text.trim());
if (normalisedHeader) {
return `${normalisedHeader}`;
}
return '';
}
/**
* Formats the given value as a row header in HTML.
*
* Removes formatting from the string and converts it to bold code style.
*
* @param value - The string to format as a header
* @returns The formatted row header string wrapped in bold and code tags
*/
export function rowHeader(value) {
if (!value) {
return '';
}
let text = value;
// Remove bold formatting
text = text.replaceAll(/\*\*(.*?)\*\*/g, '$1');
// Remove italic formatting: *italic*
text = text.replaceAll(/\*(.*?)\*/g, '$1');
// Remove strikethrough formatting: ~~strikethrough~~
text = text.replaceAll(/~~(.*?)~~/g, '$1');
// Normalize spacing
text = text.trim();
// Add bold and code formatting
return `<b><code>${text}</code></b>`;
}
/**
* Gets the version from git tags.
*/
function getVersionFromGitTag(actionDir, log) {
try {
const gitVersion = execSync('git describe --tags --abbrev=0 2>/dev/null || git tag -l "v*" --sort=-v:refname | head -1', {
cwd: actionDir,
encoding: 'utf8',
}).trim();
if (gitVersion) {
// Remove 'v' prefix if present for consistency, we'll add it back with the configured prefix
const version = gitVersion.replace(/^v/, '');
log.debug(`version from git tags: ${version}`);
return version;
}
}
catch {
log.debug(`Could not get version from git tags in ${actionDir}`);
}
return undefined;
}
/**
* Gets the current git branch name.
*/
function getVersionFromGitBranch(actionDir, log) {
try {
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
cwd: actionDir,
encoding: 'utf8',
}).trim();
if (branch && branch !== 'HEAD') {
log.debug(`version from git branch: ${branch}`);
return branch;
}
}
catch {
log.debug(`Could not get branch name in ${actionDir}`);
}
return undefined;
}
/**
* Gets the current git commit SHA (short form).
*/
function getVersionFromGitSha(actionDir, log) {
try {
const sha = execSync('git rev-parse --short HEAD', {
cwd: actionDir,
encoding: 'utf8',
}).trim();
if (sha) {
log.debug(`version from git sha: ${sha}`);
return sha;
}
}
catch {
log.debug(`Could not get commit SHA in ${actionDir}`);
}
return undefined;
}
/**
* Gets the version from package.json.
*/
function getVersionFromPackageJson(actionDir, log) {
const packageJsonPath = path.join(actionDir, 'package.json');
log.debug(`Looking for package.json at: ${packageJsonPath}`);
try {
accessSync(packageJsonPath);
const packageData = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
const version = packageData.version;
log.debug(`version from package.json: ${version ?? 'not found'}`);
return version;
}
catch {
log.debug(`package.json not found at ${packageJsonPath}`);
}
return undefined;
}
export function getCurrentVersionString(inputs) {
let versionString = '';
const log = new LogTask('getCurrentVersionString');
// Default to enabled if not explicitly set (matches action.yml default of "true")
const versioningEnabled = inputs.config.get('versioning:enabled');
const isVersioningEnabled = versioningEnabled === undefined || versioningEnabled === true || versioningEnabled === 'true';
if (isVersioningEnabled) {
log.debug('version string in generated example is enabled');
const override = inputs.config.get('versioning:override');
const versionSource = inputs.config.get('versioning:source') ?? 'git-tag';
const actionDir = path.dirname(inputs.action.path);
log.debug(`version source: ${versionSource}`);
let detectedVersion;
// If override is set and we're in explicit mode, use it directly
if (versionSource === 'explicit') {
if (override && override.length > 0) {
detectedVersion = override;
log.debug(`using explicit version override: ${detectedVersion}`);
}
else {
log.debug('explicit mode but no version_override set, falling back to 0.0.0');
detectedVersion = '0.0.0';
}
}
else {
// Get version based on selected source
switch (versionSource) {
case 'git-branch':
detectedVersion = getVersionFromGitBranch(actionDir, log);
break;
case 'git-sha':
detectedVersion = getVersionFromGitSha(actionDir, log);
break;
case 'package-json':
detectedVersion = getVersionFromPackageJson(actionDir, log);
break;
case 'git-tag':
default:
// For git-tag (default), use the legacy fallback behavior
detectedVersion = getVersionFromGitTag(actionDir, log);
if (!detectedVersion) {
detectedVersion = getVersionFromPackageJson(actionDir, log);
}
if (!detectedVersion) {
detectedVersion = process.env.npm_package_version;
log.debug(`Falling back to env:npm_package_version: ${detectedVersion ?? 'not found'}`);
}
break;
}
// Override takes precedence if set (except for explicit mode which is handled above)
if (override && override.length > 0) {
detectedVersion = override;
log.debug(`using version override: ${detectedVersion}`);
}
}
versionString = detectedVersion ?? '0.0.0';
// Get prefix, defaulting to 'v' if not set
// For git-branch and git-sha, we typically don't want a prefix
const prefix = inputs.config.get('versioning:prefix') ?? 'v';
const shouldApplyPrefix = versionSource !== 'git-branch' && versionSource !== 'git-sha';
if (shouldApplyPrefix && versionString && !versionString.startsWith(prefix)) {
versionString = `${prefix}${versionString}`;
}
}
else {
versionString = inputs.config.get('versioning:branch');
}
log.debug(`version to use in generated example is ${versionString}`);
return versionString;
}
export function indexOfRegex(str, providedRegex) {
const regex = providedRegex.global
? providedRegex
: new RegExp(providedRegex.source, `${providedRegex.flags}g`);
let index = -1;
let match = regex.exec(str);
while (match) {
index = match.index;
match = regex.exec(str);
}
return index;
}
export function lastIndexOfRegex(str, providedRegex) {
const regex = providedRegex.global
? providedRegex
: new RegExp(providedRegex.source, `${providedRegex.flags}g`);
let index = -1;
let match = regex.exec(str);
while (match) {
index = match.index + match[0].length;
match = regex.exec(str);
}
return index;
}
export function isObject(value) {
const type = typeof value;
return type === 'object' && !!value;
}
//# sourceMappingURL=helpers.js.map