nconv-cli 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ import { ConverterOptions, createConfig } from '../config.js';
2
+ import { NotionMarkdownExporter } from '../core/exporter.js';
3
+ import { generateSafeFilename, extractTitleFromUrl } from '../utils/file.js';
4
+ import * as logger from '../utils/logger.js';
5
+ import path from 'path';
6
+ import { promises as fs } from 'fs';
7
+ import os from 'os';
8
+
9
+ /**
10
+ * pdf 명령어 핸들러
11
+ * Notion → Markdown → PDF 플로우로 깔끔한 PDF 생성
12
+ */
13
+ export async function pdfCommand(notionUrl: string, options: ConverterOptions) {
14
+ const tempDir = path.join(os.tmpdir(), `nconv-cli-${Date.now()}`);
15
+
16
+ try {
17
+ // 설정 생성
18
+ const config = createConfig(options);
19
+
20
+ if (config.verbose) {
21
+ logger.info('Configuration loaded successfully');
22
+ console.log(` Output directory: ${config.output}\n`);
23
+ }
24
+
25
+ // 1. Notion에서 Markdown 가져오기
26
+ const spinner = logger.spinner('Fetching Notion page as Markdown...');
27
+
28
+ const exporter = new NotionMarkdownExporter({
29
+ tokenV2: config.tokenV2,
30
+ fileToken: config.fileToken,
31
+ });
32
+
33
+ let result;
34
+ try {
35
+ result = await exporter.exportWithImages(notionUrl, tempDir);
36
+ spinner.succeed(`Notion page fetched (${result.imageFiles.length} images)`);
37
+ } catch (error) {
38
+ spinner.fail('Failed to fetch Notion page');
39
+ throw error;
40
+ }
41
+
42
+ // 2. 파일명/폴더명 생성
43
+ let baseFilename: string;
44
+ if (config.filename) {
45
+ baseFilename = config.filename.replace(/\.pdf$/, '');
46
+ } else {
47
+ const title = extractTitleFromUrl(notionUrl);
48
+ baseFilename = generateSafeFilename(title, '');
49
+ }
50
+
51
+ // 3. 제목별 폴더 생성 (output/제목/)
52
+ const pageDir = path.join(config.output, baseFilename);
53
+ await fs.mkdir(pageDir, { recursive: true });
54
+
55
+ // 4. 이미지 폴더 생성 및 이미지 파일 이동
56
+ const imageOutputDir = path.join(pageDir, config.imageDir);
57
+ await fs.mkdir(imageOutputDir, { recursive: true });
58
+
59
+ if (config.verbose && result.imageFiles.length > 0) {
60
+ console.log(`Processing image files...\n`);
61
+ }
62
+
63
+ let processedMarkdown = result.markdown;
64
+ for (const imageFile of result.imageFiles) {
65
+ try {
66
+ const originalFileName = path.basename(imageFile.filename);
67
+ const safeFileName = originalFileName.replace(/\s+/g, '-');
68
+
69
+ // 이미지 파일 복사
70
+ const targetPath = path.join(imageOutputDir, safeFileName);
71
+ await fs.copyFile(imageFile.sourcePath, targetPath);
72
+
73
+ if (config.verbose) {
74
+ console.log(`✓ ${safeFileName}`);
75
+ }
76
+
77
+ // PDF용: 이미지를 base64로 변환
78
+ const imageBuffer = await fs.readFile(targetPath);
79
+ const base64 = imageBuffer.toString('base64');
80
+
81
+ // 파일 확장자에서 MIME 타입 결정
82
+ const ext = path.extname(safeFileName).slice(1).toLowerCase();
83
+ const mimeType = ext === 'jpg' ? 'jpeg' : ext;
84
+ const dataUrl = `data:image/${mimeType};base64,${base64}`;
85
+
86
+ // Markdown 내 경로를 처리
87
+ const originalPath = imageFile.filename;
88
+ const relativePath = `./${config.imageDir}/${safeFileName}`;
89
+
90
+ const pathParts = originalPath.split('/');
91
+ const encodedPath = pathParts.map(part => encodeURIComponent(part)).join('/');
92
+
93
+ const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
94
+
95
+ // PDF용 Markdown: base64 data URL 사용
96
+ processedMarkdown = processedMarkdown
97
+ .replace(new RegExp(`\\(${escapeRegex(originalPath)}\\)`, 'g'), `(${dataUrl})`)
98
+ .replace(new RegExp(`\\(${escapeRegex(encodedPath)}\\)`, 'g'), `(${dataUrl})`);
99
+
100
+ } catch (error) {
101
+ if (config.verbose) {
102
+ const errorMsg = error instanceof Error ? error.message : 'Unknown error';
103
+ console.error(`✗ ${imageFile.filename}: ${errorMsg}`);
104
+ }
105
+ }
106
+ }
107
+
108
+ // 5. Markdown → PDF 변환
109
+ const pdfSpinner = logger.spinner('Converting Markdown to PDF...');
110
+
111
+ const filename = `${baseFilename}.pdf`;
112
+ const pdfPath = path.join(pageDir, filename);
113
+
114
+ try {
115
+ await exporter.exportMarkdownToPDF(processedMarkdown, pdfPath, {
116
+ format: 'A4',
117
+ });
118
+ pdfSpinner.succeed('PDF generated successfully');
119
+ } catch (error) {
120
+ pdfSpinner.fail('Failed to generate PDF');
121
+ throw error;
122
+ }
123
+
124
+ // 6. 결과 출력
125
+ console.log('');
126
+ logger.success('PDF export complete!');
127
+ console.log('');
128
+ console.log(`📁 Folder: ${path.relative(process.cwd(), pageDir)}`);
129
+ console.log(`📄 PDF: ${filename}`);
130
+ if (result.imageFiles.length > 0) {
131
+ console.log(`🖼️ Images: ${config.imageDir}/ (${result.imageFiles.length} files)`);
132
+ }
133
+ console.log('');
134
+
135
+ } catch (error) {
136
+ if (error instanceof Error) {
137
+ logger.error(error.message);
138
+ } else {
139
+ logger.error('An unknown error occurred.');
140
+ }
141
+ process.exit(1);
142
+ } finally {
143
+ // 임시 디렉토리 정리
144
+ try {
145
+ await fs.rm(tempDir, { recursive: true, force: true });
146
+ } catch {
147
+ // 무시
148
+ }
149
+ }
150
+ }
package/src/config.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { config as dotenvConfig } from 'dotenv';
2
- import { existsSync } from 'fs';
3
- import { resolve } from 'path';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { resolve, join } from 'path';
4
+ import os from 'os';
5
+ import * as logger from './utils/logger.js';
4
6
 
5
- // 환경 변수 로드
6
- dotenvConfig();
7
+ // Disable dotenv default logging
8
+ process.env.DOTENV_CONFIG_SILENT = 'true';
7
9
 
8
10
  export interface NotionConfig {
9
11
  tokenV2: string;
@@ -19,21 +21,108 @@ export interface ConverterOptions {
19
21
 
20
22
  export interface FullConfig extends NotionConfig, ConverterOptions {}
21
23
 
24
+ function getConfigPath(): string {
25
+ return join(os.homedir(), '.nconv', '.env');
26
+ }
27
+
28
+ /**
29
+ * Loads environment variables from the global config file.
30
+ */
31
+ function loadEnv() {
32
+ const configPath = getConfigPath();
33
+
34
+ if (!existsSync(configPath)) {
35
+ return;
36
+ }
37
+
38
+ try {
39
+ // Manually parse .env file to avoid dotenv logging
40
+ const content = readFileSync(configPath, 'utf-8');
41
+ let loadedCount = 0;
42
+
43
+ content.split('\n').forEach((line) => {
44
+ const trimmed = line.trim();
45
+
46
+ // Skip comments and empty lines
47
+ if (!trimmed || trimmed.startsWith('#')) {
48
+ return;
49
+ }
50
+
51
+ // Parse key=value
52
+ const equalIndex = trimmed.indexOf('=');
53
+ if (equalIndex === -1) {
54
+ return;
55
+ }
56
+
57
+ const key = trimmed.substring(0, equalIndex).trim();
58
+ const value = trimmed.substring(equalIndex + 1).trim();
59
+
60
+ if (key && value) {
61
+ process.env[key] = value;
62
+ loadedCount++;
63
+ }
64
+ });
65
+
66
+ if (process.env.NCONV_VERBOSE) {
67
+ logger.info(`✓ Loaded ${loadedCount} environment variable(s) from ${configPath}`);
68
+ }
69
+ } catch (error) {
70
+ logger.error(`Failed to load configuration from: ${configPath}`);
71
+ if (error instanceof Error) {
72
+ logger.error(error.message);
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Checks if the required environment variables are set.
79
+ * Exits the process if tokens are missing (for CLI commands).
80
+ */
81
+ function checkEnv() {
82
+ const configPath = getConfigPath();
83
+ if (!process.env.TOKEN_V2 || !process.env.FILE_TOKEN) {
84
+ logger.error('Notion tokens are not set.');
85
+ if (!existsSync(configPath)) {
86
+ logger.error('Configuration file not found.');
87
+ logger.error('Please run "nconv init" to create a configuration file.');
88
+ } else {
89
+ logger.error(`Please set TOKEN_V2 and FILE_TOKEN in: ${configPath}`);
90
+ }
91
+ process.exit(1);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Validates if the required environment variables are set.
97
+ * Returns true if valid, false otherwise (for REPL mode).
98
+ */
99
+ export function validateConfig(): { valid: boolean; message?: string } {
100
+ loadEnv();
101
+ const configPath = getConfigPath();
102
+
103
+ if (!process.env.TOKEN_V2 || !process.env.FILE_TOKEN) {
104
+ if (!existsSync(configPath)) {
105
+ return {
106
+ valid: false,
107
+ message: 'Configuration file not found. Please run /init to set up your Notion tokens.',
108
+ };
109
+ } else {
110
+ return {
111
+ valid: false,
112
+ message: `Notion tokens are not set. Please run /init or /config to set up your tokens.\nConfig file: ${configPath}`,
113
+ };
114
+ }
115
+ }
116
+
117
+ return { valid: true };
118
+ }
119
+
22
120
  /**
23
121
  * Notion 인증 설정 가져오기
24
122
  */
25
123
  export function getNotionConfig(): NotionConfig {
26
124
  const tokenV2 = process.env.TOKEN_V2 || '';
27
125
  const fileToken = process.env.FILE_TOKEN || '';
28
-
29
- if (!tokenV2 || !fileToken) {
30
- throw new Error(
31
- 'Notion 토큰이 설정되지 않았습니다.\n' +
32
- '.env 파일에 TOKEN_V2와 FILE_TOKEN을 설정해주세요.\n' +
33
- '자세한 내용은 .env.example을 참고하세요.'
34
- );
35
- }
36
-
37
126
  return { tokenV2, fileToken };
38
127
  }
39
128
 
@@ -41,6 +130,9 @@ export function getNotionConfig(): NotionConfig {
41
130
  * 전체 설정 생성
42
131
  */
43
132
  export function createConfig(options: ConverterOptions): FullConfig {
133
+ loadEnv();
134
+ checkEnv();
135
+
44
136
  const notionConfig = getNotionConfig();
45
137
 
46
138
  // 출력 디렉토리 절대 경로로 변환
@@ -1,6 +1,7 @@
1
1
  import { NotionExporter } from 'notion-exporter';
2
2
  import { promises as fs } from 'fs';
3
3
  import path from 'path';
4
+ import { mdToPdf } from 'md-to-pdf';
4
5
  import type { NotionConfig } from '../config.js';
5
6
 
6
7
  export interface ExportResult {
@@ -8,14 +9,37 @@ export interface ExportResult {
8
9
  imageFiles: Array<{ filename: string; sourcePath: string }>;
9
10
  }
10
11
 
12
+ export interface HTMLExportResult {
13
+ html: string;
14
+ imageFiles: Array<{ filename: string; sourcePath: string }>;
15
+ }
16
+
17
+ export interface PDFExportResult {
18
+ pdfPath: string;
19
+ filename: string;
20
+ }
21
+
22
+ export interface MarkdownPDFOptions {
23
+ format?: 'A4' | 'Letter';
24
+ margin?: {
25
+ top?: string;
26
+ right?: string;
27
+ bottom?: string;
28
+ left?: string;
29
+ };
30
+ stylesheet?: string | string[];
31
+ }
32
+
11
33
  /**
12
34
  * Notion 페이지를 마크다운으로 내보내기
13
35
  */
14
36
  export class NotionMarkdownExporter {
15
37
  private exporter: NotionExporter;
16
38
 
17
- constructor(config: NotionConfig) {
18
- this.exporter = new NotionExporter(config.tokenV2, config.fileToken);
39
+ constructor(config: NotionConfig, exportType: 'markdown' | 'html' | 'pdf' = 'markdown') {
40
+ this.exporter = new NotionExporter(config.tokenV2, config.fileToken, {
41
+ exportType,
42
+ });
19
43
  }
20
44
 
21
45
  /**
@@ -72,4 +96,137 @@ export class NotionMarkdownExporter {
72
96
  throw new Error('Failed to fetch Notion page.');
73
97
  }
74
98
  }
99
+
100
+ /**
101
+ * Notion URL에서 HTML과 이미지 파일 가져오기
102
+ */
103
+ async exportHTML(notionUrl: string, tempDir: string): Promise<HTMLExportResult> {
104
+ try {
105
+ // 임시 디렉토리 생성
106
+ await fs.mkdir(tempDir, { recursive: true });
107
+
108
+ // getMdFiles로 HTML과 이미지를 함께 다운로드
109
+ await this.exporter.getMdFiles(notionUrl, tempDir);
110
+
111
+ // 다운로드된 파일 목록 가져오기
112
+ const files = await fs.readdir(tempDir, { withFileTypes: true });
113
+
114
+ // HTML 파일 찾기
115
+ const htmlFile = files.find(f => f.isFile() && f.name.endsWith('.html'));
116
+ if (!htmlFile) {
117
+ throw new Error('HTML file not found.');
118
+ }
119
+
120
+ // HTML 내용 읽기
121
+ const htmlPath = path.join(tempDir, htmlFile.name);
122
+ const html = await fs.readFile(htmlPath, 'utf-8');
123
+
124
+ // 이미지 파일 목록 (HTML이 아닌 파일들)
125
+ const imageFiles = files
126
+ .filter(f => f.isFile() && !f.name.endsWith('.html'))
127
+ .map(f => ({
128
+ filename: f.name,
129
+ sourcePath: path.join(tempDir, f.name),
130
+ }));
131
+
132
+ // 디렉토리 내 폴더도 확인 (Notion이 폴더 구조로 저장할 수 있음)
133
+ const dirs = files.filter(f => f.isDirectory());
134
+ for (const dir of dirs) {
135
+ const subFiles = await fs.readdir(path.join(tempDir, dir.name), { withFileTypes: true });
136
+ for (const subFile of subFiles) {
137
+ if (subFile.isFile() && !subFile.name.endsWith('.html')) {
138
+ imageFiles.push({
139
+ filename: path.join(dir.name, subFile.name),
140
+ sourcePath: path.join(tempDir, dir.name, subFile.name),
141
+ });
142
+ }
143
+ }
144
+ }
145
+
146
+ return { html, imageFiles };
147
+ } catch (error) {
148
+ if (error instanceof Error) {
149
+ throw new Error(`Failed to fetch Notion page as HTML: ${error.message}`);
150
+ }
151
+ throw new Error('Failed to fetch Notion page as HTML.');
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Markdown을 PDF로 변환 (md-to-pdf 사용)
157
+ * GitHub 스타일의 깔끔한 PDF 생성
158
+ */
159
+ async exportMarkdownToPDF(
160
+ markdownContent: string,
161
+ outputPath: string,
162
+ options: MarkdownPDFOptions = {}
163
+ ): Promise<void> {
164
+ try {
165
+ const pdfOptions = {
166
+ content: markdownContent,
167
+ stylesheet: options.stylesheet || [
168
+ 'https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.1.0/github-markdown.min.css',
169
+ ],
170
+ body_class: 'markdown-body',
171
+ css: `
172
+ .markdown-body {
173
+ box-sizing: border-box;
174
+ min-width: 200px;
175
+ max-width: 980px;
176
+ margin: 0 auto;
177
+ padding: 45px;
178
+ }
179
+
180
+ @media (max-width: 767px) {
181
+ .markdown-body {
182
+ padding: 15px;
183
+ }
184
+ }
185
+
186
+ /* 코드 블록 스타일 개선 */
187
+ .markdown-body pre {
188
+ background-color: #f6f8fa;
189
+ border-radius: 6px;
190
+ padding: 16px;
191
+ overflow: auto;
192
+ }
193
+
194
+ .markdown-body code {
195
+ background-color: rgba(175, 184, 193, 0.2);
196
+ border-radius: 6px;
197
+ padding: 0.2em 0.4em;
198
+ }
199
+
200
+ /* 이미지 스타일 */
201
+ .markdown-body img {
202
+ max-width: 100%;
203
+ height: auto;
204
+ }
205
+ `,
206
+ pdf_options: {
207
+ format: options.format || 'A4',
208
+ margin: options.margin || {
209
+ top: '20mm',
210
+ right: '20mm',
211
+ bottom: '20mm',
212
+ left: '20mm',
213
+ },
214
+ printBackground: true,
215
+ },
216
+ };
217
+
218
+ const result = await mdToPdf(pdfOptions);
219
+
220
+ if (result.content) {
221
+ await fs.writeFile(outputPath, result.content);
222
+ } else {
223
+ throw new Error('PDF content is empty');
224
+ }
225
+ } catch (error) {
226
+ if (error instanceof Error) {
227
+ throw new Error(`Failed to convert Markdown to PDF: ${error.message}`);
228
+ }
229
+ throw new Error('Failed to convert Markdown to PDF.');
230
+ }
231
+ }
75
232
  }
package/src/index.ts CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  import { Command } from 'commander';
4
4
  import { mdCommand } from './commands/md.js';
5
+ import { htmlCommand } from './commands/html.js';
6
+ import { pdfCommand } from './commands/pdf.js';
5
7
  import { debugCommand } from './commands/debug.js';
8
+ import { handler as initHandler } from './commands/init.js';
9
+ import { startRepl } from './repl/index.js';
6
10
 
7
11
  const program = new Command();
8
12
 
@@ -11,10 +15,17 @@ program
11
15
  .description('CLI tool for converting Notion pages to blog-ready markdown')
12
16
  .version('1.0.0');
13
17
 
18
+ program
19
+ .command('init')
20
+ .description('Create a default .env configuration file')
21
+ .action(async () => {
22
+ await initHandler({} as any); // Handler expects argv, but doesn't use it
23
+ });
24
+
14
25
  program
15
26
  .command('md <url>')
16
27
  .description('Convert a Notion page to markdown')
17
- .option('-o, --output <dir>', 'Output directory', './output')
28
+ .option('-o, --output <dir>', 'Output directory', './nconv-output')
18
29
  .option('-i, --image-dir <dir>', 'Image folder name (relative to output)', 'images')
19
30
  .option('-f, --filename <name>', '출력 파일명 (확장자 제외 또는 포함)')
20
31
  .option('-v, --verbose', 'Enable verbose logging', false)
@@ -27,12 +38,43 @@ program
27
38
  });
28
39
  });
29
40
 
41
+ program
42
+ .command('html <url>')
43
+ .description('Convert a Notion page to HTML')
44
+ .option('-o, --output <dir>', 'Output directory', './nconv-output')
45
+ .option('-i, --image-dir <dir>', 'Image folder name (relative to output)', 'images')
46
+ .option('-f, --filename <name>', 'Output filename (without extension or with)')
47
+ .option('-v, --verbose', 'Enable verbose logging', false)
48
+ .action(async (url: string, options) => {
49
+ await htmlCommand(url, {
50
+ output: options.output,
51
+ imageDir: options.imageDir,
52
+ filename: options.filename,
53
+ verbose: options.verbose,
54
+ });
55
+ });
56
+
57
+ program
58
+ .command('pdf <url>')
59
+ .description('Convert a Notion page to PDF (renders actual Notion page)')
60
+ .option('-o, --output <dir>', 'Output directory', './nconv-output')
61
+ .option('-f, --filename <name>', 'Output filename (without extension or with)')
62
+ .option('-v, --verbose', 'Enable verbose logging', false)
63
+ .action(async (url: string, options) => {
64
+ await pdfCommand(url, {
65
+ output: options.output,
66
+ imageDir: 'images', // Not used for PDF, but required by interface
67
+ filename: options.filename,
68
+ verbose: options.verbose,
69
+ });
70
+ });
71
+
30
72
  // 개발 환경에서만 debug 명령어를 활성화
31
73
  if (process.env.NODE_ENV !== 'production') {
32
74
  program
33
75
  .command('debug <url>')
34
76
  .description('Debug: Output raw markdown and image URLs')
35
- .option('-o, --output <dir>', '출력 디렉토리', './output')
77
+ .option('-o, --output <dir>', '출력 디렉토리', './nconv-output')
36
78
  .option('-i, --image-dir <dir>', 'Image folder name', 'images')
37
79
  .option('-v, --verbose', 'Enable verbose logging', false)
38
80
  .action(async (url: string, options) => {
@@ -45,4 +87,11 @@ if (process.env.NODE_ENV !== 'production') {
45
87
  });
46
88
  }
47
89
 
48
- program.parse();
90
+ // If no arguments provided, start REPL mode
91
+ (async () => {
92
+ if (process.argv.length === 2) {
93
+ await startRepl();
94
+ } else {
95
+ program.parse();
96
+ }
97
+ })();