Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { createApp } from './app.ts';
import { registerHelpCommand } from './commands/help.ts';

const program = createApp();

registerHelpCommand(program);

program.parseAsync(process.argv).catch((err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
Expand Down
212 changes: 212 additions & 0 deletions src/commands/help.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import type { Command } from 'commander';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'node:path';
import { getDefaultsDir } from '../config.ts';
import * as output from '../utils/output.ts';

/**
* Prints the decorated banner and command list.
*
* @since TBD
*
* @param {string} contents - The raw markdown contents of commands.md.
*
* @returns {void}
*/
function printCommandList(contents: string): void {
const border = '*'.repeat(80);

output.writeln(border);
output.writeln('*');
output.writeln(`* ${chalk.blue(`${chalk.magenta('P')}roduct ${chalk.magenta('U')}tility & ${chalk.magenta('P')}ackager`)}`);
output.writeln('* ' + '-'.repeat(78));
output.writeln('* A CLI utility by StellarWP');
output.writeln('*');
output.writeln(border);

output.newline();
output.writeln(`Run ${chalk.cyan('pup help <topic>')} for more information on a specific command.`);
output.newline();

const lines = contents.split('\n');
const commands: [string, string][] = [];
let currentCommand: string | null = null;

for (const line of lines) {
const headerMatch = line.match(/##+\s+`pup ([^`]+)`/);
if (headerMatch) {
currentCommand = headerMatch[1];
continue;
}

if (currentCommand && !commands.find(([cmd]) => cmd === currentCommand)) {
const trimmed = line.trim();
if (trimmed) {
const description = trimmed.replace(/`([^`]+)`/g, (_, code: string) => chalk.cyan(code));
commands.push([currentCommand, description]);
currentCommand = null;
}
}
}

commands.sort((a, b) => a[0].localeCompare(b[0]));

output.table(
['Command', 'Description'],
commands.map(([cmd, desc]) => [chalk.yellow(cmd), desc]),
);

output.newline();
output.writeln(`For more documentation, head over to ${chalk.yellow('https://github.com/stellarwp/pup')}`);
}

/**
* Prints styled help for a specific command topic.
*
* @since TBD
*
* @param {string} topic - The command topic to show help for.
* @param {string} contents - The raw markdown contents of commands.md.
*
* @returns {boolean} Whether the topic was found.
*/
function printCommandHelp(topic: string, contents: string): boolean {
const lines = contents.split('\n');
let started = false;
let didFirstLine = false;
let inCodeBlock = false;
let inArgTable = false;
let argHeaders: string[] = [];
let argRows: string[][] = [];

for (const line of lines) {
if (started) {
// Stop when we hit the next ## command section
if (/^##+\s+`pup /.test(line)) {
break;
}

// Code block toggle
if (/^```/.test(line)) {
inCodeBlock = !inCodeBlock;
output.writeln(chalk.green('.'.repeat(50)));
continue;
}

// Inside code block
if (inCodeBlock) {
if (/^#/.test(line)) {
output.writeln(chalk.green(line));
} else {
output.writeln(chalk.cyan(line));
}
continue;
}

// Apply inline formatting (strip links first so ANSI codes from chalk
// don't contain `[` characters that confuse the link regex)
let formatted = line;
formatted = formatted.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
formatted = formatted.replace(/`([^`]+)`/g, (_, code: string) => chalk.cyan(code));
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, (_, bold: string) => chalk.red(bold));

// Argument/option table handling
if (inArgTable) {
if (/^\| (Arg|Opt)/.test(formatted)) {
argHeaders = formatted.replace(/^\||\|$/g, '').split('|').map((s) => s.trim());
continue;
}

if (/^\|--/.test(line)) {
continue;
}

if (/^\|/.test(line)) {
argRows.push(formatted.replace(/^\||\|$/g, '').split('|').map((s) => s.trim()));
continue;
}

// End of table
if (argHeaders.length > 0) {
inArgTable = false;
output.table(argHeaders, argRows);
argHeaders = [];
argRows = [];
}
}

// Sub-section headers (### or ####)
const sectionMatch = formatted.match(/^##(#+) (.+)/);
if (sectionMatch) {
const depth = sectionMatch[1].length;
const label = sectionMatch[2];
output.section(`${'>'.repeat(depth)} ${label}:`);

if (/^##(#+ )(Arguments|`\.puprc` options)/.test(line)) {
inArgTable = true;
argHeaders = [];
argRows = [];
}
continue;
}

output.writeln(formatted);

if (!didFirstLine) {
didFirstLine = true;
output.newline();
}
} else {
const topicPattern = new RegExp(`^##+\\s+\`pup ${topic.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\``);
if (topicPattern.test(line)) {
output.title(`Help: ${chalk.cyan('pup ' + topic)}`);
started = true;
}
}
}

// Flush any remaining argument table
if (inArgTable && argHeaders.length > 0) {
output.table(argHeaders, argRows);
}

return started;
}

/**
* Registers the `help` command with the CLI program.
*
* @since TBD
*
* @param {Command} program - The Commander.js program instance.
*
* @returns {void}
*/
export function registerHelpCommand(program: Command): void {
program
.command('help [topic]', { isDefault: true })
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isDefault: true is to replicate how in the PHP version, php pup runs php pup help by default.

.description('Shows help for pup.')
.action(async (topic?: string) => {
const docsDir = path.resolve(getDefaultsDir(), '..', 'docs');
const commandsPath = path.join(docsDir, 'commands.md');

if (!await fs.pathExists(commandsPath)) {
output.log('Help documentation not found.');
return;
}

const contents = await fs.readFile(commandsPath, 'utf-8');

if (!topic) {
printCommandList(contents);
return;
}

const normalizedTopic = topic.replace('pup ', '').replace('pup-', '');

if (!printCommandHelp(normalizedTopic, contents)) {
output.error(`Unknown topic: ${topic}`);
}
});
}
62 changes: 57 additions & 5 deletions src/utils/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export function info(message: string): void {
}

/**
* Prints a bold title with an underline rule.
* Prints a yellow title with an underline rule.
*
* @since TBD
*
Expand All @@ -105,13 +105,13 @@ export function info(message: string): void {
*/
export function title(message: string): void {
console.log('');
console.log(formatMessage(chalk.bold(message)));
console.log(formatMessage(chalk.bold('='.repeat(message.length))));
console.log(formatMessage(chalk.yellow(message)));
console.log(formatMessage(chalk.yellow('='.repeat(message.length))));
console.log('');
}

/**
* Prints a bold yellow section header.
* Prints a yellow section header.
*
* @since TBD
*
Expand All @@ -121,7 +121,9 @@ export function title(message: string): void {
*/
export function section(message: string): void {
console.log('');
console.log(formatMessage(chalk.bold.yellow(message)));
console.log(formatMessage(chalk.yellow(message)));
console.log(formatMessage(chalk.yellow('-'.repeat(message.length))));
console.log('');
}

/**
Expand Down Expand Up @@ -160,3 +162,53 @@ export function writeln(message: string): void {
export function newline(): void {
console.log('');
}

/**
* Strips ANSI escape codes from a string for accurate length calculation.
*
* @since TBD
*
* @param {string} str - The string potentially containing ANSI codes.
*
* @returns {string} The string with ANSI codes removed.
*/
function stripAnsi(str: string): string {
return str.replace(/\x1b\[[0-9;]*m/g, '');
}

/**
* Renders a formatted ASCII table to stdout.
*
* @since TBD
*
* @param {string[]} headers - Column header labels.
* @param {string[][]} rows - Array of row data, each row being an array of cell strings.
*
* @returns {void}
*/
export function table(headers: string[], rows: string[][]): void {
const colWidths: number[] = headers.map((h) => stripAnsi(h).length);

for (const row of rows) {
for (let i = 0; i < row.length; i++) {
const len = stripAnsi(row[i] || '').length;
if (len > (colWidths[i] || 0)) {
colWidths[i] = len;
}
}
}

const separator = '|' + colWidths.map((w) => '-'.repeat(w + 2)).join('|') + '|';
const formatRow = (cells: string[]): string => {
return '| ' + cells.map((cell, i) => {
const padding = (colWidths[i] || 0) - stripAnsi(cell || '').length;
return (cell || '') + ' '.repeat(Math.max(0, padding));
}).join(' | ') + ' |';
};

console.log(formatRow(headers));
console.log(separator);
for (const row of rows) {
console.log(formatRow(row));
}
}
Loading