From 571b857e71b4a2b34d4dd15d7ef0babdddf7f217 Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Sat, 4 Apr 2026 20:13:55 +0900 Subject: [PATCH 1/2] feat: add local format conversion command Add `convert` command for offline conversion between content formats (markdown, storage, html, text) without requiring a Confluence server connection. Closes #91 --- .claude/skills/confluence/SKILL.md | 44 +++++++++++- README.md | 11 +++ bin/confluence.js | 87 ++++++++++++++++++++++ lib/confluence-client.js | 7 ++ tests/convert.test.js | 111 +++++++++++++++++++++++++++++ 5 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 tests/convert.test.js diff --git a/.claude/skills/confluence/SKILL.md b/.claude/skills/confluence/SKILL.md index 63470eb..9a62764 100644 --- a/.claude/skills/confluence/SKILL.md +++ b/.claude/skills/confluence/SKILL.md @@ -1,11 +1,11 @@ --- name: confluence -description: Use confluence-cli to read, search, create, update, move, and delete Confluence pages and attachments from the terminal. +description: Use confluence-cli to read, search, create, update, move, delete, and convert Confluence pages and attachments from the terminal. --- # confluence-cli Skill -A CLI tool for Atlassian Confluence. Lets you read, search, create, update, move, and delete pages and attachments from the terminal or from an agent. +A CLI tool for Atlassian Confluence. Lets you read, search, create, update, move, delete, and convert pages and attachments from the terminal or from an agent. ## Installation @@ -627,6 +627,36 @@ confluence stats --- +### `convert` + +Convert between content formats locally without a Confluence server connection. + +```sh +confluence convert [--input-file ] [--output-file ] --input-format --output-format +``` + +| Option | Default | Description | +|---|---|---| +| `--input-file`, `-i` | stdin | Input file path | +| `--output-file`, `-o` | stdout | Output file path | +| `--input-format` | — | Input format: `markdown`, `storage`, `html` (required) | +| `--output-format` | — | Output format: `markdown`, `storage`, `html`, `text` (required) | + +Supported conversions: markdown→storage, markdown→html, markdown→text, html→storage, html→text, html→markdown, storage→markdown, storage→html, storage→text. + +```sh +# Markdown to Confluence storage format +confluence convert -i doc.md -o doc.xml --input-format markdown --output-format storage + +# Pipe via stdin/stdout +echo "# Hello" | confluence convert --input-format markdown --output-format storage + +# Storage format back to markdown +confluence convert -i page.xml --input-format storage --output-format markdown +``` + +--- + ### `install-skill` Copy the Claude Code skill documentation into your project's `.claude/skills/` directory so Claude Code can learn confluence-cli automatically. @@ -682,6 +712,16 @@ confluence copy-tree 123456789 987654321 --dry-run confluence copy-tree 123456789 987654321 "Backup Copy" ``` +### Offline format conversion + +```sh +# Convert markdown to Confluence storage format (no server needed) +confluence convert -i doc.md -o doc.xml --input-format markdown --output-format storage + +# Convert storage format to markdown for editing +confluence convert -i page.xml -o page.md --input-format storage --output-format markdown +``` + ### Export a page for local editing ```sh diff --git a/README.md b/README.md index 527ea57..a56d5eb 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A powerful command-line interface for Atlassian Confluence that allows you to re - 🛠️ **Edit workflow** - Export page content for editing and re-import - 🔀 **Profiles** - Manage multiple Confluence instances with named configuration profiles - 🔒 **Read-only mode** - Profile-level write protection for safe AI agent usage +- 🔄 **Format conversion** - Convert between Markdown, HTML, Storage, and text formats locally (no server required) - 🔧 **Easy setup** - Simple configuration with environment variables or interactive setup ## Installation @@ -555,6 +556,7 @@ confluence stats | `profile use ` | Set the active configuration profile | | | `profile add ` | Add a new configuration profile | `-d, --domain`, `-p, --api-path`, `-a, --auth-type`, `-e, --email`, `-t, --token`, `--protocol`, `--read-only` | | `profile remove ` | Remove a configuration profile | | +| `convert` | Convert between content formats locally (no server required) | `--input-file `, `--output-file `, `--input-format `, `--output-format ` | | `stats` | View your usage statistics | | **Global option:** `--profile ` — Use a specific profile for any command (overrides `CONFLUENCE_PROFILE` env var and active profile). @@ -597,6 +599,15 @@ confluence stats confluence profile list confluence profile use staging confluence --profile staging spaces + +# Convert markdown to Confluence storage format (no server required) +confluence convert --input-file doc.md --input-format markdown --output-format storage + +# Pipe conversion via stdin/stdout +echo "# Hello" | confluence convert --input-format markdown --output-format storage + +# Convert storage format back to markdown +confluence convert -i page.xml -o page.md --input-format storage --output-format markdown ``` ## Development diff --git a/bin/confluence.js b/bin/confluence.js index 2623843..eda2bf5 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -1952,6 +1952,93 @@ profileCmd } }); +// Convert command (local format conversion, no server connection required) +const VALID_INPUT_FORMATS = ['markdown', 'storage', 'html']; +const VALID_OUTPUT_FORMATS = ['markdown', 'storage', 'html', 'text']; + +program + .command('convert') + .description('Convert between content formats locally (no server connection required)') + .option('-i, --input-file ', 'Input file path (reads from stdin if omitted)') + .option('-o, --output-file ', 'Output file path (writes to stdout if omitted)') + .option('--input-format ', `Input format (${VALID_INPUT_FORMATS.join(', ')})`) + .option('--output-format ', `Output format (${VALID_OUTPUT_FORMATS.join(', ')})`) + .action(async (options) => { + const analytics = new Analytics(); + try { + if (!options.inputFormat) { + console.error(chalk.red('Error: --input-format is required.')); + process.exit(1); + } + if (!options.outputFormat) { + console.error(chalk.red('Error: --output-format is required.')); + process.exit(1); + } + if (!VALID_INPUT_FORMATS.includes(options.inputFormat)) { + console.error(chalk.red(`Error: Invalid input format "${options.inputFormat}". Valid: ${VALID_INPUT_FORMATS.join(', ')}`)); + process.exit(1); + } + if (!VALID_OUTPUT_FORMATS.includes(options.outputFormat)) { + console.error(chalk.red(`Error: Invalid output format "${options.outputFormat}". Valid: ${VALID_OUTPUT_FORMATS.join(', ')}`)); + process.exit(1); + } + if (options.inputFormat === options.outputFormat) { + console.error(chalk.red('Error: Input and output formats must be different.')); + process.exit(1); + } + + const fs = require('fs'); + let input; + if (options.inputFile) { + input = fs.readFileSync(options.inputFile, 'utf-8'); + } else { + input = fs.readFileSync('/dev/stdin', 'utf-8'); + } + + const converter = ConfluenceClient.createLocalConverter(); + let output; + + if (options.inputFormat === 'markdown' && options.outputFormat === 'storage') { + output = converter.markdownToStorage(input); + } else if (options.inputFormat === 'markdown' && options.outputFormat === 'html') { + output = converter.markdown.render(input); + } else if (options.inputFormat === 'html' && options.outputFormat === 'storage') { + output = converter.htmlToConfluenceStorage(input); + } else if (options.inputFormat === 'storage' && options.outputFormat === 'markdown') { + output = converter.storageToMarkdown(input); + } else if (options.inputFormat === 'storage' && options.outputFormat === 'text') { + const { convert: htmlToText } = require('html-to-text'); + output = htmlToText(input, { wordwrap: 130 }); + } else if (options.inputFormat === 'storage' && options.outputFormat === 'html') { + output = input; // storage format is already HTML-based + } else if (options.inputFormat === 'html' && options.outputFormat === 'text') { + const { convert: htmlToText } = require('html-to-text'); + output = htmlToText(input, { wordwrap: 130 }); + } else if (options.inputFormat === 'html' && options.outputFormat === 'markdown') { + output = converter.storageToMarkdown(input); + } else if (options.inputFormat === 'markdown' && options.outputFormat === 'text') { + const html = converter.markdown.render(input); + const { convert: htmlToText } = require('html-to-text'); + output = htmlToText(html, { wordwrap: 130 }); + } else { + console.error(chalk.red(`Error: Conversion from "${options.inputFormat}" to "${options.outputFormat}" is not supported.`)); + process.exit(1); + } + + if (options.outputFile) { + fs.writeFileSync(options.outputFile, output, 'utf-8'); + console.error(chalk.green(`Converted ${options.inputFormat} → ${options.outputFormat}: ${options.outputFile}`)); + } else { + process.stdout.write(output); + } + analytics.track('convert', true); + } catch (error) { + analytics.track('convert', false); + console.error(chalk.red('Error:'), error.message); + process.exit(1); + } + }); + // Exported for testing module.exports = { program, diff --git a/lib/confluence-client.js b/lib/confluence-client.js index 6e73ecb..2d106f6 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -2104,5 +2104,12 @@ class ConfluenceClient { } } +ConfluenceClient.createLocalConverter = function () { + const instance = Object.create(ConfluenceClient.prototype); + instance.markdown = new MarkdownIt(); + instance.setupConfluenceMarkdownExtensions(); + return instance; +}; + module.exports = ConfluenceClient; module.exports.NAMED_ENTITIES = NAMED_ENTITIES; \ No newline at end of file diff --git a/tests/convert.test.js b/tests/convert.test.js new file mode 100644 index 0000000..a0ed686 --- /dev/null +++ b/tests/convert.test.js @@ -0,0 +1,111 @@ +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const ConfluenceClient = require('../lib/confluence-client'); + +const CLI = path.resolve(__dirname, '../bin/index.js'); + +function run(args, input) { + return execFileSync(process.execPath, [CLI, ...args], { + encoding: 'utf8', + input, + timeout: 10000, + }); +} + +describe('createLocalConverter', () => { + test('creates instance without server config', () => { + const converter = ConfluenceClient.createLocalConverter(); + expect(converter).toBeInstanceOf(ConfluenceClient); + expect(converter.markdown).toBeDefined(); + }); + + test('converts markdown to storage format', () => { + const converter = ConfluenceClient.createLocalConverter(); + const result = converter.markdownToStorage('# Hello'); + expect(result).toContain('

'); + expect(result).toContain('Hello'); + }); + + test('converts storage to markdown', () => { + const converter = ConfluenceClient.createLocalConverter(); + const result = converter.storageToMarkdown('

Hello

World

'); + expect(result).toContain('# Hello'); + expect(result).toContain('World'); + }); +}); + +describe('convert command', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'confluence-convert-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('markdown to storage via stdin/stdout', () => { + const output = run( + ['convert', '--input-format', 'markdown', '--output-format', 'storage'], + '# Hello\n\nWorld\n' + ); + expect(output).toContain('

'); + expect(output).toContain('Hello'); + expect(output).toContain('World'); + }); + + test('markdown to storage via files', () => { + const inputFile = path.join(tmpDir, 'input.md'); + const outputFile = path.join(tmpDir, 'output.xml'); + fs.writeFileSync(inputFile, '# Test\n\nParagraph\n'); + run(['convert', '--input-file', inputFile, '--output-file', outputFile, '--input-format', 'markdown', '--output-format', 'storage']); + const output = fs.readFileSync(outputFile, 'utf-8'); + expect(output).toContain('

'); + expect(output).toContain('Test'); + }); + + test('storage to markdown', () => { + const output = run( + ['convert', '--input-format', 'storage', '--output-format', 'markdown'], + '

Title

Content

' + ); + expect(output).toContain('# Title'); + expect(output).toContain('Content'); + }); + + test('markdown to html', () => { + const output = run( + ['convert', '--input-format', 'markdown', '--output-format', 'html'], + '**bold**' + ); + expect(output).toContain('bold'); + }); + + test('storage to text', () => { + const output = run( + ['convert', '--input-format', 'storage', '--output-format', 'text'], + '

Title

Content

' + ); + expect(output.toLowerCase()).toContain('title'); + expect(output).toContain('Content'); + }); + + test('errors on missing --input-format', () => { + expect(() => run(['convert', '--output-format', 'storage'], '')).toThrow(); + }); + + test('errors on missing --output-format', () => { + expect(() => run(['convert', '--input-format', 'markdown'], '')).toThrow(); + }); + + test('errors on same input and output format', () => { + expect(() => run(['convert', '--input-format', 'markdown', '--output-format', 'markdown'], '')).toThrow(); + }); + + test('errors on invalid format', () => { + expect(() => run(['convert', '--input-format', 'xml', '--output-format', 'storage'], '')).toThrow(); + }); +}); From 78deb9b878dec9f46874c7d88c91887a62376af4 Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Sat, 4 Apr 2026 20:24:12 +0900 Subject: [PATCH 2/2] fix: use process.stdin.fd and file-based tests for CI compatibility Replace /dev/stdin with process.stdin.fd for cross-platform stdin reading. Convert stdin-based tests to file-based to avoid ENXIO errors in CI environments where stdin is not available. --- bin/confluence.js | 2 +- tests/convert.test.js | 47 ++++++++++++++++++++++--------------------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/bin/confluence.js b/bin/confluence.js index eda2bf5..245eaef 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -1992,7 +1992,7 @@ program if (options.inputFile) { input = fs.readFileSync(options.inputFile, 'utf-8'); } else { - input = fs.readFileSync('/dev/stdin', 'utf-8'); + input = fs.readFileSync(process.stdin.fd, 'utf-8'); } const converter = ConfluenceClient.createLocalConverter(); diff --git a/tests/convert.test.js b/tests/convert.test.js index a0ed686..dcc1286 100644 --- a/tests/convert.test.js +++ b/tests/convert.test.js @@ -47,20 +47,23 @@ describe('convert command', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - test('markdown to storage via stdin/stdout', () => { - const output = run( - ['convert', '--input-format', 'markdown', '--output-format', 'storage'], - '# Hello\n\nWorld\n' - ); + function writeInput(name, content) { + const p = path.join(tmpDir, name); + fs.writeFileSync(p, content); + return p; + } + + test('markdown to storage via stdout', () => { + const inputFile = writeInput('input.md', '# Hello\n\nWorld\n'); + const output = run(['convert', '--input-file', inputFile, '--input-format', 'markdown', '--output-format', 'storage']); expect(output).toContain('

'); expect(output).toContain('Hello'); expect(output).toContain('World'); }); test('markdown to storage via files', () => { - const inputFile = path.join(tmpDir, 'input.md'); + const inputFile = writeInput('input.md', '# Test\n\nParagraph\n'); const outputFile = path.join(tmpDir, 'output.xml'); - fs.writeFileSync(inputFile, '# Test\n\nParagraph\n'); run(['convert', '--input-file', inputFile, '--output-file', outputFile, '--input-format', 'markdown', '--output-format', 'storage']); const output = fs.readFileSync(outputFile, 'utf-8'); expect(output).toContain('

'); @@ -68,44 +71,42 @@ describe('convert command', () => { }); test('storage to markdown', () => { - const output = run( - ['convert', '--input-format', 'storage', '--output-format', 'markdown'], - '

Title

Content

' - ); + const inputFile = writeInput('input.xml', '

Title

Content

'); + const output = run(['convert', '--input-file', inputFile, '--input-format', 'storage', '--output-format', 'markdown']); expect(output).toContain('# Title'); expect(output).toContain('Content'); }); test('markdown to html', () => { - const output = run( - ['convert', '--input-format', 'markdown', '--output-format', 'html'], - '**bold**' - ); + const inputFile = writeInput('input.md', '**bold**'); + const output = run(['convert', '--input-file', inputFile, '--input-format', 'markdown', '--output-format', 'html']); expect(output).toContain('bold'); }); test('storage to text', () => { - const output = run( - ['convert', '--input-format', 'storage', '--output-format', 'text'], - '

Title

Content

' - ); + const inputFile = writeInput('input.xml', '

Title

Content

'); + const output = run(['convert', '--input-file', inputFile, '--input-format', 'storage', '--output-format', 'text']); expect(output.toLowerCase()).toContain('title'); expect(output).toContain('Content'); }); test('errors on missing --input-format', () => { - expect(() => run(['convert', '--output-format', 'storage'], '')).toThrow(); + const inputFile = writeInput('input.md', ''); + expect(() => run(['convert', '--input-file', inputFile, '--output-format', 'storage'])).toThrow(); }); test('errors on missing --output-format', () => { - expect(() => run(['convert', '--input-format', 'markdown'], '')).toThrow(); + const inputFile = writeInput('input.md', ''); + expect(() => run(['convert', '--input-file', inputFile, '--input-format', 'markdown'])).toThrow(); }); test('errors on same input and output format', () => { - expect(() => run(['convert', '--input-format', 'markdown', '--output-format', 'markdown'], '')).toThrow(); + const inputFile = writeInput('input.md', ''); + expect(() => run(['convert', '--input-file', inputFile, '--input-format', 'markdown', '--output-format', 'markdown'])).toThrow(); }); test('errors on invalid format', () => { - expect(() => run(['convert', '--input-format', 'xml', '--output-format', 'storage'], '')).toThrow(); + const inputFile = writeInput('input.md', ''); + expect(() => run(['convert', '--input-file', inputFile, '--input-format', 'xml', '--output-format', 'storage'])).toThrow(); }); });