Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
79 changes: 28 additions & 51 deletions src/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,83 +32,60 @@ export async function checkUrl(url: string, options: CheckOptions): Promise<Chec
const started = Date.now();
const isHttp = isInsecureHttp(url);

const result = (fields: ResultFields): CheckResult => ({
url,
isHttp,
redirectChain: [],
durationMs: Date.now() - started,
...fields,
});

if (!isValidUrl(url)) {
return {
url,
return result({
status: 'malformed',
redirectChain: [],
isHttp,
durationMs: Date.now() - started,
error: 'URL is malformed or uses an unsupported protocol',
};
});
}

const requestFn = options.requestFn ?? performRequest;

try {
const { statusCode, redirectChain, finalUrl } = await requestFn(url, options.timeout);
const durationMs = Date.now() - started;

if (statusCode >= 200 && statusCode < 300) {
return {
url,
status: redirectChain.length > 0 ? 'redirect' : 'ok',
statusCode,
redirectChain,
finalUrl,
isHttp,
durationMs,
};
}

if (statusCode >= 300 && statusCode < 400) {
return {
url,
status: 'redirect',
statusCode,
redirectChain,
finalUrl,
isHttp,
durationMs,
};
}
const status = classifyStatusCode(statusCode, redirectChain.length > 0);

return {
url,
status: 'broken',
return result({
status,
statusCode,
redirectChain,
finalUrl,
isHttp,
durationMs,
error: `Received HTTP ${statusCode}`,
};
error: status === 'broken' ? `Received HTTP ${statusCode}` : undefined,
});
} catch (error) {
const durationMs = Date.now() - started;
const axiosError = error as AxiosError;

if (axiosError.code === 'ECONNABORTED' || axiosError.message?.includes('timeout')) {
return {
url,
return result({
status: 'timeout',
redirectChain: [],
isHttp,
durationMs,
error: `Request timed out after ${options.timeout}ms`,
};
});
}

return {
url,
return result({
status: 'broken',
redirectChain: [],
isHttp,
durationMs,
error: axiosError.message ?? 'Unknown network error',
};
});
}
}

type ResultFields = Omit<CheckResult, 'url' | 'isHttp' | 'durationMs' | 'redirectChain'> &
Partial<Pick<CheckResult, 'redirectChain'>>;

function classifyStatusCode(statusCode: number, redirected: boolean): LinkStatus {
if (statusCode >= 200 && statusCode < 300) return redirected ? 'redirect' : 'ok';
if (statusCode >= 300 && statusCode < 400) return 'redirect';
return 'broken';
}

interface RawRequestResult {
statusCode: number;
redirectChain: string[];
Expand Down
13 changes: 5 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { resolveConfig } from './config.js';
import { runScan } from './scanner.js';
import { printConsoleReport, printVerboseLine } from './formatter.js';
import { toJsonReport, toMarkdownReport, writeJsonReport } from './report.js';
import { shouldFailScan } from './link-status.js';
import { toErrorMessage } from './utils.js';
import packageJson from '../package.json' with { type: 'json' };

const program = new Command();
Expand Down Expand Up @@ -94,20 +96,15 @@ async function main(path: string, rawOptions: Record<string, unknown>): Promise<
if (!config.json) console.log(chalk.dim(`Report written to ${config.output}`));
}

const shouldFail =
(config.failOnBroken && report.summary.broken > 0) ||
(config.failOnRedirect && report.summary.redirects > 0) ||
(config.failOnHttp && report.summary.insecureHttp > 0);

process.exitCode = shouldFail ? 1 : 0;
process.exitCode = shouldFailScan(report.summary, config) ? 1 : 0;
} catch (error) {
spinner?.fail('Scan failed');
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
console.error(chalk.red(toErrorMessage(error)));
process.exitCode = 2;
}
}

program.parseAsync(process.argv).catch((error: unknown) => {
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
console.error(chalk.red(toErrorMessage(error)));
process.exitCode = 2;
});
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Public URL of the URLDN shortener, referenced in reports and console output. */
export const URLDN_SHORTENER_URL = 'https://www.urldn.com';
161 changes: 92 additions & 69 deletions src/formatter.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,65 @@
import chalk from 'chalk';
import chalk, { type ChalkInstance } from 'chalk';
import type { ScanReport, ScannedLinkResult } from './scanner.js';
import { formatDuration, truncateUrl } from './utils.js';
import { URLDN_SHORTENER_URL } from './constants.js';
import { isFailureStatus, isHealthy, STATUS_DISPLAY, SUMMARY_ROWS, type StatusColor } from './link-status.js';

const URLDN_SHORTENER_URL = 'https://www.urldn.com';
const COLORS: Record<StatusColor, ChalkInstance> = {
green: chalk.green,
yellow: chalk.yellow,
red: chalk.red,
cyan: chalk.cyan,
gray: chalk.gray,
};

/** Prints a single line for a checked link while a scan is in progress (verbose mode). */
export function printVerboseLine(result: ScannedLinkResult): void {
const icon = statusIcon(result);
const { symbol, color } = STATUS_DISPLAY[result.status];
const url = truncateUrl(result.url, 90);
console.log(`${icon} ${url} ${chalk.dim(`(${formatDuration(result.durationMs)})`)}`);
console.log(`${COLORS[color](symbol)} ${url} ${chalk.dim(`(${formatDuration(result.durationMs)})`)}`);
}

function statusIcon(result: ScannedLinkResult): string {
switch (result.status) {
case 'ok':
return chalk.green('✓');
case 'redirect':
return chalk.yellow('⚠');
case 'broken':
return chalk.red('✖');
case 'timeout':
return chalk.red('⏱');
case 'malformed':
return chalk.red('✖');
default:
return chalk.gray('•');
interface Section {
heading: string;
icon: string;
color: StatusColor;
results: ScannedLinkResult[];
/** Maximum number of entries to print before collapsing the rest. */
limit?: number;
/** Extra text appended to the URL line. */
suffix?: (result: ScannedLinkResult) => string;
/** Additional dimmed lines printed underneath the URL line. */
details?: (result: ScannedLinkResult) => string[];
/** Dimmed line printed once, after all entries. */
footer?: string;
}

/** Prints one titled group of results, sharing layout across all report sections. */
function printSection(section: Section): void {
const { results, limit, color, icon } = section;
if (results.length === 0) return;

const paint = COLORS[color];
console.log(paint(`\n${section.heading} (${results.length})`));

const shown = limit === undefined ? results : results.slice(0, limit);
for (const result of shown) {
const suffix = section.suffix?.(result) ?? '';
console.log(` ${paint(icon)} ${truncateUrl(result.url)}${suffix}`);
for (const detail of section.details?.(result) ?? []) {
console.log(chalk.dim(` ${detail}`));
}
}

if (limit !== undefined && results.length > limit) {
console.log(chalk.dim(` ... and ${results.length - limit} more`));
}
if (section.footer) console.log(chalk.dim(` ${section.footer}`));
}

function redirectChainLine(result: ScannedLinkResult): string[] {
if (result.redirectChain.length <= 1) return [];
return [`chain: ${[...result.redirectChain, result.finalUrl].join(' → ')}`];
}

/** Prints the full human-readable console report after a scan completes. */
Expand All @@ -36,58 +70,50 @@ export function printConsoleReport(report: ScanReport): void {
console.log(chalk.bold('urldn-link-check report'));
console.log(chalk.dim('─'.repeat(48)));

const ok = results.filter((r) => r.status === 'ok' && !r.isHttp && !r.isTooLong);
const redirects = results.filter((r) => r.status === 'redirect');
const broken = results.filter(
(r) => r.status === 'broken' || r.status === 'malformed' || r.status === 'timeout',
);
const insecure = results.filter((r) => r.isHttp);
const tooLong = results.filter((r) => r.isTooLong);
printSection({
heading: '✓ Valid links',
icon: '✓',
color: 'green',
results: results.filter(isHealthy),
limit: 20,
});

if (ok.length > 0) {
console.log(chalk.green(`\n✓ Valid links (${ok.length})`));
for (const result of ok.slice(0, 20)) {
console.log(` ${chalk.green('✓')} ${truncateUrl(result.url)}`);
}
if (ok.length > 20) console.log(chalk.dim(` ... and ${ok.length - 20} more`));
}
printSection({
heading: '⚠ Redirects',
icon: '⚠',
color: 'yellow',
results: results.filter((r) => r.status === 'redirect'),
suffix: (result) => ` ${chalk.dim(`[${result.statusCode}]`)}`,
details: redirectChainLine,
});

if (redirects.length > 0) {
console.log(chalk.yellow(`\n⚠ Redirects (${redirects.length})`));
for (const result of redirects) {
console.log(` ${chalk.yellow('⚠')} ${truncateUrl(result.url)} ${chalk.dim(`[${result.statusCode}]`)}`);
if (result.redirectChain.length > 1) {
console.log(chalk.dim(` chain: ${[...result.redirectChain, result.finalUrl].join(' → ')}`));
}
}
}

if (broken.length > 0) {
console.log(chalk.red(`\n✖ Broken (${broken.length})`));
for (const result of broken) {
printSection({
heading: '✖ Broken',
icon: '✖',
color: 'red',
results: results.filter((r) => isFailureStatus(r.status)),
suffix: (result) => {
const location = result.occurrences[0];
const where = location ? ` ${chalk.dim(`(${location.file}:${location.line})`)}` : '';
console.log(` ${chalk.red('✖')} ${truncateUrl(result.url)}${where}`);
if (result.error) console.log(chalk.dim(` ${result.error}`));
}
}
return location ? ` ${chalk.dim(`(${location.file}:${location.line})`)}` : '';
},
details: (result) => (result.error ? [result.error] : []),
});

if (insecure.length > 0) {
console.log(chalk.yellow(`\n🔓 Insecure HTTP (${insecure.length})`));
for (const result of insecure) {
console.log(` ${chalk.yellow('🔓')} ${truncateUrl(result.url)}`);
}
}
printSection({
heading: '🔓 Insecure HTTP',
icon: '🔓',
color: 'yellow',
results: results.filter((r) => r.isHttp),
});

if (tooLong.length > 0) {
console.log(chalk.cyan(`\n⚡ Long URLs (${tooLong.length})`));
for (const result of tooLong) {
console.log(
` ${chalk.cyan('⚡')} ${truncateUrl(result.url)} ${chalk.dim(`(${result.url.length} chars)`)}`,
);
}
console.log(chalk.dim(` Suggestion: shorten long URLs with URLDN — ${URLDN_SHORTENER_URL}`));
}
printSection({
heading: '⚡ Long URLs',
icon: '⚡',
color: 'cyan',
results: results.filter((r) => r.isTooLong),
suffix: (result) => ` ${chalk.dim(`(${result.url.length} chars)`)}`,
footer: `Suggestion: shorten long URLs with URLDN — ${URLDN_SHORTENER_URL}`,
});

console.log('');
console.log(chalk.dim('─'.repeat(48)));
Expand All @@ -97,10 +123,7 @@ export function printConsoleReport(report: ScanReport): void {
`Time: ${formatDuration(summary.durationMs)}`,
);
console.log(
` ${chalk.green(`${summary.ok} ok`)} ${chalk.yellow(`${summary.redirects} redirect`)} ` +
`${chalk.red(`${summary.broken} broken`)} ${chalk.red(`${summary.timeouts} timeout`)} ` +
`${chalk.red(`${summary.malformed} malformed`)} ${chalk.yellow(`${summary.insecureHttp} http`)} ` +
`${chalk.cyan(`${summary.tooLong} long`)}`,
` ${SUMMARY_ROWS.map((row) => COLORS[row.color](`${summary[row.key]} ${row.shortLabel}`)).join(' ')}`,
);
console.log('');
}
Loading
Loading