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
44 changes: 42 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { parseLinkedomHTML } from './utils/linkedom-compat';
import { countWords } from './utils';
import { buildFrontmatter } from './frontmatter';
import { getInitialUA, fetchPage, extractRawMarkdown, cleanMarkdownContent, BOT_UA } from './fetch';
import type { DebugInfo, DebugRemoval } from './types';

export interface ParseOptions {
output?: string;
Expand All @@ -23,6 +24,8 @@ export interface ParseOptions {

interface ParseResult {
output: string;
/** Human-readable debug log, present only when --debug and not --json. */
debugLog?: string;
}

// ANSI color helpers (avoids chalk dependency which is ESM-only)
Expand Down Expand Up @@ -145,12 +148,43 @@ export async function parseSource(source: string | undefined, options: ParseOpti
wordCount: result.wordCount,
...(result.contentMarkdown ? { contentMarkdown: result.contentMarkdown } : {}),
...(result.variables ? { variables: result.variables } : {}),
...(result.debug ? { debug: result.debug } : {}),
}, null, 2);
} else {
output = options.frontmatter ? buildFrontmatter(result, url) + result.content : result.content;
}

return { output };
// Surface debug info when --debug was set without --json. The content
// goes to stdout (unchanged); the debug log goes to stderr so it does
// not corrupt the primary output stream. We format it here so callers
// (tests, action wrapper) can route it wherever they want.
const debugLog = options.debug && !options.json && result.debug
? formatDebugLog(result.debug)
: undefined;

return { output, debugLog };
}

function formatDebugLog(debug: DebugInfo): string {
const lines: string[] = [];
lines.push(`# defuddle --debug`);
lines.push(`contentSelector: ${debug.contentSelector || '(none)'}`);
lines.push(`removals: ${debug.removals.length}`);
for (const r of debug.removals) {
lines.push(formatRemoval(r));
}
return lines.join('\n') + '\n';
}

function formatRemoval(r: DebugRemoval): string {
const parts: string[] = [];
parts.push(`[${r.step}]`);
if (r.reason) parts.push(r.reason);
if (r.selector) parts.push(`(${r.selector})`);
// `text` is a short preview from the source; keep it on one line.
const preview = r.text.replace(/\s+/g, ' ').trim();
parts.push(`— ${preview}`);
return parts.join(' ');
}

export function createProgram(): Command {
Expand All @@ -176,7 +210,7 @@ export function createProgram(): Command {
.option('-u, --user-agent <string>', 'Custom User-Agent header for HTTP requests (helps with 403/FORBIDDEN responses)')
.action(async (source: string | undefined, options: ParseOptions) => {
try {
const { output } = await parseSource(source, options);
const { output, debugLog } = await parseSource(source, options);

// Handle output
if (options.output) {
Expand All @@ -186,6 +220,12 @@ export function createProgram(): Command {
} else {
console.log(output);
}

// In --debug mode without --json, surface the removal log on
// stderr so it does not interleave with the primary output.
if (debugLog) {
process.stderr.write(debugLog);
}
} catch (error) {
console.error(ansi.red('Error:'), error instanceof Error ? error.message : 'Unknown error occurred');
process.exit(1);
Expand Down
34 changes: 34 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,38 @@ describe('CLI parseSource', () => {
// commander camelCases --user-agent → options.userAgent, which parseSource reads.
expect(option?.attributeName()).toBe('userAgent');
});

test('--debug --json embeds the debug payload alongside other fields', async () => {
const result = await parseSource('-', { json: true, debug: true }, createMockStdin(fixtureHtml));
const parsed = JSON.parse(result.output);

// Existing fields stay; new `debug` field is now present.
expect(parsed.title).toBeDefined();
expect(parsed.debug).toBeDefined();
expect(typeof parsed.debug.contentSelector).toBe('string');
expect(Array.isArray(parsed.debug.removals)).toBe(true);
// debugLog is reserved for the non-JSON path; --json should not emit it.
expect(result.debugLog).toBeUndefined();
});

test('--debug without --json returns a human-readable debugLog for stderr', async () => {
const result = await parseSource('-', { debug: true }, createMockStdin(fixtureHtml));

expect(typeof result.debugLog).toBe('string');
expect(result.debugLog).toContain('# defuddle --debug');
expect(result.debugLog).toContain('contentSelector:');
expect(result.debugLog).toContain('removals:');
// Primary output remains the content body (stdout-safe — the debug
// log goes via the returned `debugLog` to be written to stderr).
expect(result.output).not.toContain('# defuddle --debug');
});

test('omits debug payload entirely when --debug is not set', async () => {
const jsonResult = await parseSource('-', { json: true }, createMockStdin(fixtureHtml));
const parsed = JSON.parse(jsonResult.output);
expect(parsed.debug).toBeUndefined();

const plain = await parseSource('-', {}, createMockStdin(fixtureHtml));
expect(plain.debugLog).toBeUndefined();
});
});