@wonseok-han/confluence-sync 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/dist/colors.js +20 -0
- package/dist/config.js +28 -0
- package/dist/confluence.js +242 -0
- package/dist/docs.js +120 -0
- package/dist/help.js +57 -0
- package/dist/html2md.js +137 -0
- package/dist/ignore.js +25 -0
- package/dist/init.js +120 -0
- package/dist/mapping.js +16 -0
- package/dist/markdown.js +112 -0
- package/dist/pull.js +151 -0
- package/dist/sync.js +288 -0
- package/package.json +60 -0
package/dist/help.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** --help / --version 출력. */
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { bold, cyan, dim } from './colors.js';
|
|
5
|
+
/** 설치된 패키지(package.json)의 버전을 읽는다(dist·src 어느 쪽에서 실행해도 ../package.json). */
|
|
6
|
+
export function readPkgVersion() {
|
|
7
|
+
try {
|
|
8
|
+
const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
9
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? 'unknown';
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return 'unknown';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function printHelp() {
|
|
16
|
+
const h = (s) => bold(s); // 섹션 제목
|
|
17
|
+
const o = (s) => cyan(s); // 옵션/명령
|
|
18
|
+
console.log(`${bold(cyan(`confluence-sync v${readPkgVersion()}`))}
|
|
19
|
+
${dim('Markdown 디렉토리를 Confluence Cloud 페이지로 단방향 동기화합니다.')}
|
|
20
|
+
|
|
21
|
+
${h('사용법:')}
|
|
22
|
+
${o('confluence-sync')} [옵션] [경로...] 문서를 동기화(md → Confluence)
|
|
23
|
+
${o('confluence-sync init')} 대화형으로 .env 설정 파일 생성
|
|
24
|
+
${o('confluence-sync pull <pageId|url>')} Confluence 페이지/폴더를 .md 로 가져오기(역방향)
|
|
25
|
+
${o('confluence-sync --help | --version')}
|
|
26
|
+
|
|
27
|
+
${h('동기화 옵션:')}
|
|
28
|
+
${o('--base <dir>')} 동기화 루트 (또는 env CONFLUENCE_SYNC_BASE). 미지정 시 중단
|
|
29
|
+
${o('--mapping <path>')} 매핑 파일 위치 (기본: <base>/.confluence-sync.json)
|
|
30
|
+
${o('--exclude <glob>')} 동기화 제외 패턴(반복 가능). <base>/.confluence-syncignore(.gitignore 방식)도 사용
|
|
31
|
+
${o('--dry-run')} 호출 없이 대상·상태(신규/변경/동일)·링크·이미지 출력
|
|
32
|
+
${o('--list')} 인식된 문서·제목·계층만 출력 (Confluence 호출·인증 없음)
|
|
33
|
+
${o('--force')} 변경 감지 무시, 전체 강제 갱신
|
|
34
|
+
${o('--verify')} 변경 없는 문서도 페이지 존재 확인(삭제됐으면 재생성)
|
|
35
|
+
${o('--rebuild')} 매핑된 페이지 전부 삭제 후 재생성
|
|
36
|
+
${o('--no-color')} 컬러 출력 끄기
|
|
37
|
+
${o('-h, --help')} 이 도움말
|
|
38
|
+
${o('-v, --version')} 버전 출력
|
|
39
|
+
|
|
40
|
+
${h('경로 인자:')}
|
|
41
|
+
파일/폴더를 주면 그 문서만 동기화합니다(부모 README 자동 포함).
|
|
42
|
+
예) ${o('confluence-sync 20-design')} ${o('confluence-sync 90-glossary.md')}
|
|
43
|
+
|
|
44
|
+
${h('pull(역방향) 옵션:')}
|
|
45
|
+
${o('--out <dir>')} .md 를 생성할 디렉토리(기본: 현재 폴더)
|
|
46
|
+
${o('--children')} 하위 페이지까지 재귀적으로 가져와 폴더 트리로 복원
|
|
47
|
+
${o('--space')} 대상 없이 스페이스(CONFLUENCE_SPACE_KEY) 홈페이지부터 전체를 가져옴
|
|
48
|
+
(이미지는 항상 attachments/<문서명>/ 하위에 저장됨)
|
|
49
|
+
예) ${o('confluence-sync pull https://.../pages/12345/Title --out ./docs --children')}
|
|
50
|
+
${o('confluence-sync pull --space --out ./docs')}
|
|
51
|
+
|
|
52
|
+
${h('설정(.env):')}
|
|
53
|
+
실행 위치(cwd)의 .env 또는 셸 환경변수를 읽습니다.
|
|
54
|
+
필수: CONFLUENCE_BASE_URL, CONFLUENCE_EMAIL, CONFLUENCE_API_TOKEN, CONFLUENCE_SPACE_KEY
|
|
55
|
+
선택: CONFLUENCE_PARENT_ID(페이지/폴더 id), CONFLUENCE_SYNC_BASE
|
|
56
|
+
${o("'confluence-sync init'")} 으로 대화형 생성 가능.`);
|
|
57
|
+
}
|
package/dist/html2md.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confluence export_view(HTML) → Markdown 변환.
|
|
3
|
+
* turndown(HTML→MD) + Confluence 특화 규칙(code 매크로·첨부 이미지·표).
|
|
4
|
+
* storage/export_view 는 무손실 역변환이 아니므로 근사 결과다.
|
|
5
|
+
*/
|
|
6
|
+
import TurndownService from 'turndown';
|
|
7
|
+
/** storage(XHTML)의 code 매크로들에서 language 파라미터를 문서 순서대로 추출(미지정은 ''). */
|
|
8
|
+
export function codeLanguagesFromStorage(storage) {
|
|
9
|
+
const out = [];
|
|
10
|
+
const macro = /<ac:structured-macro[^>]*ac:name="code"[\s\S]*?<\/ac:structured-macro>/g;
|
|
11
|
+
let m;
|
|
12
|
+
while ((m = macro.exec(storage)) !== null) {
|
|
13
|
+
const lang = m[0].match(/ac:name="language"[^>]*>([^<]*)</);
|
|
14
|
+
out.push(lang ? lang[1].trim() : '');
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
/** 공백이 있으면 <...> 로 감싼 마크다운 링크 목적지(공백 있는 경로도 안전하게 파싱되도록) */
|
|
19
|
+
const linkDest = (p) => (/\s/.test(p) ? `<${p}>` : p);
|
|
20
|
+
/**
|
|
21
|
+
* turndown 은 텍스트 노드 단위로 이스케이프해서, 줄 중간인데도 마커로 오인해 \- \+ \. 을 남긴다.
|
|
22
|
+
* (예: `**7️⃣**\-**1️⃣**` — 두 굵은 글씨 사이 텍스트 노드가 "-" 하나라 노드 시작으로 취급)
|
|
23
|
+
* 줄 맨 앞의 진짜 마커 이스케이프는 보존하고, 그 뒤(줄 중간)의 것만 되돌린다. 코드블록은 건드리지 않는다.
|
|
24
|
+
*/
|
|
25
|
+
function relaxEscapes(md) {
|
|
26
|
+
let inFence = false;
|
|
27
|
+
return md
|
|
28
|
+
.split('\n')
|
|
29
|
+
.map((line) => {
|
|
30
|
+
if (/^\s*```/.test(line)) {
|
|
31
|
+
inFence = !inFence;
|
|
32
|
+
return line;
|
|
33
|
+
}
|
|
34
|
+
if (inFence)
|
|
35
|
+
return line;
|
|
36
|
+
// 들여쓰기 + (줄 맨 앞의 이스케이프된 마커) 까지는 그대로 두고 이후만 완화
|
|
37
|
+
const head = line.match(/^\s*(?:\\[-+>#]|\d+\\\.)?/)?.[0] ?? '';
|
|
38
|
+
const rest = line
|
|
39
|
+
.slice(head.length)
|
|
40
|
+
// 줄 중간의 - + . > 는 마커가 아니므로 이스케이프 불필요
|
|
41
|
+
.replace(/\\([-+.>])/g, '$1')
|
|
42
|
+
// 링크가 아닌 대괄호 쌍(뒤에 ( 나 [ 가 오지 않음)은 이스케이프 불필요
|
|
43
|
+
.replace(/\\\[([^[\]\n]*)\\\](?!\s*[([])/g, '[$1]');
|
|
44
|
+
return head + rest;
|
|
45
|
+
})
|
|
46
|
+
.join('\n');
|
|
47
|
+
}
|
|
48
|
+
export function htmlToMarkdown(html, opts = {}) {
|
|
49
|
+
const td = new TurndownService({
|
|
50
|
+
headingStyle: 'atx',
|
|
51
|
+
codeBlockStyle: 'fenced',
|
|
52
|
+
bulletListMarker: '-',
|
|
53
|
+
hr: '---',
|
|
54
|
+
emDelimiter: '_',
|
|
55
|
+
});
|
|
56
|
+
// Confluence export_view 는 색상 텍스트용 인라인 <style> 을 본문(제목 안까지) 끼워 넣는다.
|
|
57
|
+
// turndown 은 기본적으로 이런 요소의 텍스트를 그대로 뱉으므로 통째로 제거한다.
|
|
58
|
+
td.remove(['style', 'script', 'noscript', 'head', 'meta', 'link']);
|
|
59
|
+
// turndown 은 _ 를 강조로 오인할까봐 전부 \_ 로 이스케이프한다.
|
|
60
|
+
// 그러나 단어 내부 밑줄은 CommonMark 에서 강조가 아니므로(:11_eleven_square_blue: 같은 이모지 코드)
|
|
61
|
+
// 양쪽이 단어 문자인 경우만 이스케이프를 되돌린다.
|
|
62
|
+
const baseEscape = td.escape.bind(td);
|
|
63
|
+
td.escape = (s) => baseEscape(s).replace(/(\w)\\_(?=\w)/g, '$1_');
|
|
64
|
+
// Confluence 는 리스트 항목 내용을 <li><p>…</p></li> 로 감싼다.
|
|
65
|
+
// 기본 규칙대로면 문단으로 취급돼 항목마다 빈 줄이 생기므로(loose list),
|
|
66
|
+
// li 안의 첫 문단은 빈 줄 없이 붙인다(두 번째 이후 문단은 기존대로 분리).
|
|
67
|
+
td.addRule('liParagraph', {
|
|
68
|
+
filter: (node) => node.nodeName === 'P' && node.parentNode?.nodeName === 'LI',
|
|
69
|
+
replacement: (content, node) => {
|
|
70
|
+
const el = node;
|
|
71
|
+
return el.previousElementSibling ? `\n\n${content}` : content;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
// 코드블록: Confluence code 매크로는 export_view 에서 <pre ...syntaxhighlighter...> 로 렌더된다.
|
|
75
|
+
// 언어는 storage 기반 codeLangs 를 우선 사용(개수가 맞을 때). 없으면 brush 로 폴백.
|
|
76
|
+
const preCount = (html.match(/<pre[\s>]/gi) || []).length;
|
|
77
|
+
const langs = opts.codeLangs && opts.codeLangs.length === preCount ? opts.codeLangs : undefined;
|
|
78
|
+
let preIdx = 0;
|
|
79
|
+
td.addRule('confCode', {
|
|
80
|
+
filter: (node) => node.nodeName === 'PRE',
|
|
81
|
+
replacement: (_content, node) => {
|
|
82
|
+
const el = node;
|
|
83
|
+
const i = preIdx++;
|
|
84
|
+
let lang;
|
|
85
|
+
if (langs) {
|
|
86
|
+
lang = langs[i] ?? '';
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const params = el.getAttribute('data-syntaxhighlighter-params') || '';
|
|
90
|
+
const m = params.match(/brush:\s*([^;]+)/i);
|
|
91
|
+
lang = m ? m[1].trim() : '';
|
|
92
|
+
}
|
|
93
|
+
// 원본에 줄 끝 공백이 잔뜩 붙어 있는 경우가 많아 트림(코드 의미는 불변)
|
|
94
|
+
const code = (el.textContent || '').replace(/[ \t]+$/gm, '').replace(/\n+$/, '');
|
|
95
|
+
return `\n\n\`\`\`${lang || 'plaintext'}\n${code}\n\`\`\`\n\n`;
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
// 이미지: 첨부 → 로컬 파일명으로, 외부 URL 이미지 → 절대 URL 유지.
|
|
99
|
+
td.addRule('confImage', {
|
|
100
|
+
filter: 'img',
|
|
101
|
+
replacement: (_content, node) => {
|
|
102
|
+
const el = node;
|
|
103
|
+
const src = el.getAttribute('src') || '';
|
|
104
|
+
const alt = el.getAttribute('alt') || '';
|
|
105
|
+
const isAttachment = el.getAttribute('data-linked-resource-type') === 'attachment' ||
|
|
106
|
+
/\/download\/(attachments|thumbnails)\//.test(src);
|
|
107
|
+
if (!isAttachment && /^https?:\/\//i.test(src)) {
|
|
108
|
+
return `})`;
|
|
109
|
+
}
|
|
110
|
+
const alias = el.getAttribute('data-linked-resource-default-alias') || '';
|
|
111
|
+
const name = alias || decodeURIComponent((src.split('?')[0].split('/').pop() || '').trim());
|
|
112
|
+
if (name)
|
|
113
|
+
opts.onImage?.(name);
|
|
114
|
+
const dest = opts.assetPrefix ? `${opts.assetPrefix}/${name}` : name;
|
|
115
|
+
return `})`;
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
// 표 → GFM 마크다운 표(turndown 기본은 표 미지원).
|
|
119
|
+
td.addRule('confTable', {
|
|
120
|
+
filter: 'table',
|
|
121
|
+
replacement: (_content, node) => {
|
|
122
|
+
const cell = (c) => td.turndown(c.innerHTML || '').replace(/\n+/g, ' ').replace(/\|/g, '\\|').trim();
|
|
123
|
+
const trs = Array.from(node.querySelectorAll('tr'));
|
|
124
|
+
const matrix = trs.map((r) => Array.from(r.children).filter((c) => c.nodeName === 'TD' || c.nodeName === 'TH').map(cell));
|
|
125
|
+
const rows = matrix.filter((r) => r.length);
|
|
126
|
+
if (!rows.length)
|
|
127
|
+
return '';
|
|
128
|
+
const header = rows[0];
|
|
129
|
+
const sep = header.map(() => '---');
|
|
130
|
+
let out = `\n\n| ${header.join(' | ')} |\n| ${sep.join(' | ')} |\n`;
|
|
131
|
+
for (const r of rows.slice(1))
|
|
132
|
+
out += `| ${r.join(' | ')} |\n`;
|
|
133
|
+
return `${out}\n`;
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
return relaxEscapes(td.turndown(html)).trim() + '\n';
|
|
137
|
+
}
|
package/dist/ignore.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 동기화 제외 규칙. base 루트의 .confluence-syncignore(.gitignore 방식) + --exclude 옵션.
|
|
3
|
+
* 매칭은 npm `ignore`(gitignore 스펙) 사용. 경로는 base 기준 상대(POSIX).
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import ignore from 'ignore';
|
|
8
|
+
export const IGNORE_FILE = '.confluence-syncignore';
|
|
9
|
+
export function buildIgnorer(baseDir, excludes) {
|
|
10
|
+
const ig = ignore();
|
|
11
|
+
let active = false;
|
|
12
|
+
const file = join(baseDir, IGNORE_FILE);
|
|
13
|
+
if (existsSync(file)) {
|
|
14
|
+
ig.add(readFileSync(file, 'utf8'));
|
|
15
|
+
active = true;
|
|
16
|
+
}
|
|
17
|
+
if (excludes.length) {
|
|
18
|
+
ig.add(excludes);
|
|
19
|
+
active = true;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
ignores: (rel) => active && ig.ignores(rel),
|
|
23
|
+
active,
|
|
24
|
+
};
|
|
25
|
+
}
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `confluence-sync init` — 대화형으로 cwd 에 .env 를 생성한다.
|
|
3
|
+
* TTY 가 아니면(파이프/CI) 값 없는 빈 템플릿을 쓴다. 토큰 입력은 *로 가린다.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import { createInterface } from 'node:readline/promises';
|
|
8
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
9
|
+
import { bold, cyan, dim, green, red, yellow } from './colors.js';
|
|
10
|
+
/** 일반 한 줄 입력(에코 표시). 매 질문마다 readline 을 새로 열고 닫아 stdin 경쟁을 피한다. */
|
|
11
|
+
async function promptLine(query) {
|
|
12
|
+
const rl = createInterface({ input, output });
|
|
13
|
+
try {
|
|
14
|
+
return (await rl.question(query)).trim();
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
rl.close();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** 비밀 입력: raw 모드로 직접 읽어 *로 가린다(readline 미사용 → 에코 충돌 없음). */
|
|
21
|
+
function promptSecret(query) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
output.write(query);
|
|
24
|
+
const wasRaw = input.isRaw ?? false;
|
|
25
|
+
input.setRawMode?.(true);
|
|
26
|
+
input.resume();
|
|
27
|
+
let val = '';
|
|
28
|
+
const cleanup = () => {
|
|
29
|
+
input.removeListener('data', onData);
|
|
30
|
+
input.setRawMode?.(wasRaw);
|
|
31
|
+
input.pause();
|
|
32
|
+
};
|
|
33
|
+
const onData = (buf) => {
|
|
34
|
+
for (const ch of buf.toString('utf8')) {
|
|
35
|
+
if (ch === '\r' || ch === '\n' || ch === '\u0004') { // Enter / Ctrl+D
|
|
36
|
+
cleanup();
|
|
37
|
+
output.write('\n');
|
|
38
|
+
resolve(val.trim());
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (ch === '\u0003') { // Ctrl+C
|
|
42
|
+
cleanup();
|
|
43
|
+
output.write('\n');
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
if (ch === '\u007f' || ch === '\b') { // Backspace
|
|
47
|
+
if (val.length) {
|
|
48
|
+
val = val.slice(0, -1);
|
|
49
|
+
output.write('\b \b');
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (ch >= ' ') {
|
|
54
|
+
val += ch;
|
|
55
|
+
output.write('*');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
input.on('data', onData);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export async function runInit(argv) {
|
|
63
|
+
const target = resolve(process.cwd(), '.env');
|
|
64
|
+
const force = argv.includes('--force');
|
|
65
|
+
const fields = [
|
|
66
|
+
{ key: 'CONFLUENCE_BASE_URL', hint: 'https://api.atlassian.com/ex/confluence/<CLOUD_ID>/wiki 형식 (CLOUD_ID: <도메인>.atlassian.net/_edge/tenant_info 에서 확인)' },
|
|
67
|
+
{ key: 'CONFLUENCE_EMAIL', hint: 'Atlassian 계정 이메일' },
|
|
68
|
+
{ key: 'CONFLUENCE_API_TOKEN', hint: 'Scoped API 토큰 (id.atlassian.com → Create API token with scopes). 입력은 가려집니다', secret: true },
|
|
69
|
+
{ key: 'CONFLUENCE_SPACE_KEY', hint: 'URL .../wiki/spaces/<KEY>/... 의 KEY' },
|
|
70
|
+
{ key: 'CONFLUENCE_PARENT_ID', hint: '루트 앵커(페이지 또는 폴더 id). 비우면 공간 최상위', optional: true },
|
|
71
|
+
{ key: 'CONFLUENCE_SYNC_BASE', hint: `동기화 루트 디렉토리 (기본: ${process.cwd()})`, optional: true, def: process.cwd() },
|
|
72
|
+
];
|
|
73
|
+
const renderEnv = (vals) => '# confluence-sync 설정 (confluence-sync init 으로 생성)\n\n' +
|
|
74
|
+
fields.map((f) => `# ${f.hint}\n${f.key}=${vals[f.key] ?? ''}`).join('\n\n') +
|
|
75
|
+
'\n';
|
|
76
|
+
// 덮어쓰기 가드
|
|
77
|
+
if (existsSync(target) && !force) {
|
|
78
|
+
if (!input.isTTY) {
|
|
79
|
+
console.error(red(`✗ .env 가 이미 있습니다: ${target}`) + `\n 덮어쓰려면: ${cyan('confluence-sync init --force')}`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
const rlConfirm = createInterface({ input, output });
|
|
83
|
+
const ans = await rlConfirm.question(`.env 가 이미 존재합니다 (${target}). 덮어쓸까요? [y/N] `);
|
|
84
|
+
rlConfirm.close();
|
|
85
|
+
if (!/^y(es)?$/i.test(ans.trim())) {
|
|
86
|
+
console.log('취소했습니다.');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// 비대화형(파이프/CI): 값 없이 빈 템플릿만 생성
|
|
91
|
+
if (!input.isTTY) {
|
|
92
|
+
writeFileSync(target, renderEnv({}));
|
|
93
|
+
console.log(`템플릿 .env 를 생성했습니다: ${target}\n각 값을 채워 넣으세요.`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// 대화형 마법사
|
|
97
|
+
console.log(bold('confluence-sync .env 설정 마법사'));
|
|
98
|
+
console.log(dim('각 항목을 입력하세요. (Enter 로 비우면 빈 값 또는 기본값)\n'));
|
|
99
|
+
const values = {};
|
|
100
|
+
try {
|
|
101
|
+
for (const f of fields) {
|
|
102
|
+
const label = `${cyan(f.key)}${f.optional ? dim(' (선택)') : ''}\n ${dim(f.hint)}\n ${cyan('>')} `;
|
|
103
|
+
let v = f.secret ? await promptSecret(label) : await promptLine(label);
|
|
104
|
+
if (!v && f.def)
|
|
105
|
+
v = f.def;
|
|
106
|
+
values[f.key] = v;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
// 일반 입력에서 Ctrl+D(EOF) → AbortError. 깔끔히 취소
|
|
111
|
+
if (e?.name === 'AbortError') {
|
|
112
|
+
output.write(yellow('\n취소했습니다. (.env 미생성)\n'));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
throw e;
|
|
116
|
+
}
|
|
117
|
+
writeFileSync(target, renderEnv(values));
|
|
118
|
+
console.log(green(`\n✓ .env 생성 완료: ${target}`));
|
|
119
|
+
console.log(` 다음으로: ${cyan('confluence-sync --dry-run')} ${dim('(대상·상태 미리보기)')}`);
|
|
120
|
+
}
|
package/dist/mapping.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 매핑(파일 경로 ↔ pageId ↔ hash) 영속화.
|
|
3
|
+
* 기본 위치는 <base>/.confluence-sync.json 이며 문서셋과 함께 버전관리된다.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
export function loadMapping(path) {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function saveMapping(path, m) {
|
|
15
|
+
writeFileSync(path, JSON.stringify(m, null, 2) + '\n');
|
|
16
|
+
}
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown → Confluence storage format 변환.
|
|
3
|
+
* - 코드블록 → code 매크로
|
|
4
|
+
* - 내부 .md 링크 → 페이지 링크(ri:page, 대상 제목 기반)
|
|
5
|
+
* - 로컬 이미지 → 첨부 참조(ri:attachment), 외부 URL 이미지는 그대로
|
|
6
|
+
* 모든 경로는 baseDir 기준 상대경로로 해석한다.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
+
import { resolve, relative, dirname, basename } from 'node:path';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import MarkdownIt from 'markdown-it';
|
|
12
|
+
export const escapeXml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
13
|
+
export const hashOf = (s) => createHash('sha256').update(s).digest('hex');
|
|
14
|
+
/** markdown-it 이 퍼센트 인코딩한 링크/이미지 경로를 실제 경로로 디코드(실패 시 원본). */
|
|
15
|
+
const decodePath = (s) => {
|
|
16
|
+
try {
|
|
17
|
+
return decodeURIComponent(s);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return s;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const md = new MarkdownIt({ html: true, linkify: true, breaks: false });
|
|
24
|
+
// 렌더 규칙은 md 인스턴스에 한 번만 등록되므로, 현재 변환 컨텍스트를 모듈 변수로 공유한다.
|
|
25
|
+
let ctx = { fileDir: '', titleIndex: {}, images: [], internalLinks: 0, linkStack: [], linkedTitles: new Set() };
|
|
26
|
+
let baseDir = '';
|
|
27
|
+
/** 링크 href 가 base 내부 .md 면 그 페이지 제목 반환 */
|
|
28
|
+
function resolveInternalLink(href) {
|
|
29
|
+
if (!href || /^(https?:|mailto:|#)/i.test(href))
|
|
30
|
+
return null;
|
|
31
|
+
const pathPart = href.split('#')[0];
|
|
32
|
+
if (!pathPart || !/\.md$/i.test(pathPart))
|
|
33
|
+
return null;
|
|
34
|
+
const rel = relative(baseDir, resolve(baseDir, ctx.fileDir, decodePath(pathPart)));
|
|
35
|
+
return ctx.titleIndex[rel] ?? null;
|
|
36
|
+
}
|
|
37
|
+
md.renderer.rules.fence = (tokens, idx) => {
|
|
38
|
+
const t = tokens[idx];
|
|
39
|
+
const lang = (t.info || '').trim().split(/\s+/)[0];
|
|
40
|
+
const safe = t.content.split(']]>').join(']]]]><![CDATA[>');
|
|
41
|
+
const langParam = lang ? `<ac:parameter ac:name="language">${lang}</ac:parameter>` : '';
|
|
42
|
+
return `<ac:structured-macro ac:name="code">${langParam}<ac:plain-text-body><![CDATA[${safe}]]></ac:plain-text-body></ac:structured-macro>\n`;
|
|
43
|
+
};
|
|
44
|
+
md.renderer.rules.link_open = (tokens, idx, opts, _env, self) => {
|
|
45
|
+
const href = tokens[idx].attrGet('href') || '';
|
|
46
|
+
const targetTitle = resolveInternalLink(href);
|
|
47
|
+
if (targetTitle) {
|
|
48
|
+
ctx.linkStack.push(true);
|
|
49
|
+
ctx.internalLinks++;
|
|
50
|
+
ctx.linkedTitles.add(targetTitle);
|
|
51
|
+
return `<ac:link><ri:page ri:content-title="${escapeXml(targetTitle)}" /><ac:link-body>`;
|
|
52
|
+
}
|
|
53
|
+
ctx.linkStack.push(false);
|
|
54
|
+
return self.renderToken(tokens, idx, opts);
|
|
55
|
+
};
|
|
56
|
+
md.renderer.rules.link_close = (tokens, idx, opts, _env, self) => ctx.linkStack.pop() ? '</ac:link-body></ac:link>' : self.renderToken(tokens, idx, opts);
|
|
57
|
+
// 이미지 기본 최대 표시 폭(px). Confluence 본문 폭을 넘는 큰 원본이 영역을 벗어나는 것을 막는다.
|
|
58
|
+
// 개별 이미지는 markdown title 로 덮어쓴다: 
|
|
59
|
+
const DEFAULT_IMAGE_WIDTH = 760;
|
|
60
|
+
md.renderer.rules.image = (tokens, idx) => {
|
|
61
|
+
const token = tokens[idx];
|
|
62
|
+
const src = token.attrGet('src') || '';
|
|
63
|
+
const title = token.attrGet('title') || '';
|
|
64
|
+
const m = title.match(/width\s*=\s*(\d+)/i);
|
|
65
|
+
const width = m ? m[1] : String(DEFAULT_IMAGE_WIDTH);
|
|
66
|
+
const widthAttr = width ? ` ac:width="${escapeXml(width)}"` : '';
|
|
67
|
+
if (/^https?:\/\//i.test(src)) {
|
|
68
|
+
return `<ac:image${widthAttr}><ri:url ri:value="${escapeXml(src)}" /></ac:image>`;
|
|
69
|
+
}
|
|
70
|
+
// markdown-it 이 링크 경로를 퍼센트 인코딩하므로 디코드해서 실제 파일명/경로로 복원(한글·공백 대응)
|
|
71
|
+
const abs = resolve(baseDir, ctx.fileDir, decodePath(src.split('#')[0]));
|
|
72
|
+
const filename = basename(abs);
|
|
73
|
+
ctx.images.push({ filename, abs });
|
|
74
|
+
return `<ac:image${widthAttr}><ri:attachment ri:filename="${escapeXml(filename)}" /></ac:image>`;
|
|
75
|
+
};
|
|
76
|
+
/** 첫 H1(`# 제목`)을 제목으로 추출하고 본문에서 제거 */
|
|
77
|
+
export function splitTitleAndBody(markdown, fallback) {
|
|
78
|
+
const lines = markdown.split('\n');
|
|
79
|
+
const i = lines.findIndex((l) => /^#\s+/.test(l));
|
|
80
|
+
if (i === -1)
|
|
81
|
+
return { title: fallback, body: markdown };
|
|
82
|
+
const title = lines[i].replace(/^#\s+/, '').trim();
|
|
83
|
+
lines.splice(i, 1);
|
|
84
|
+
return { title, body: lines.join('\n') };
|
|
85
|
+
}
|
|
86
|
+
/** 본문 markdown 을 storage format 으로 변환(base 기준 상대경로로 링크·이미지 해석). */
|
|
87
|
+
export function toStorage(markdown, rel, titleIndex, base) {
|
|
88
|
+
baseDir = base;
|
|
89
|
+
ctx = { fileDir: dirname(rel) === '.' ? '' : dirname(rel), titleIndex, images: [], internalLinks: 0, linkStack: [], linkedTitles: new Set() };
|
|
90
|
+
const storage = md.render(markdown);
|
|
91
|
+
return { storage, images: ctx.images, internalLinks: ctx.internalLinks, linkedTitles: [...ctx.linkedTitles] };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 변경 감지용 문서 해시: 제목 + storage + 참조 로컬 이미지의 내용 해시.
|
|
95
|
+
* 이미지 내용을 포함하므로, 같은 파일명으로 이미지만 교체해도 해시가 바뀌어 재동기화 대상이 된다.
|
|
96
|
+
* 이미지가 없으면 hashOf(title + '\0' + storage) 와 동일(이미지 없는 문서는 기존 해시 유지).
|
|
97
|
+
*/
|
|
98
|
+
export function docHash(title, r) {
|
|
99
|
+
const parts = [title, r.storage];
|
|
100
|
+
for (const img of r.images) {
|
|
101
|
+
let digest = 'missing';
|
|
102
|
+
try {
|
|
103
|
+
if (existsSync(img.abs))
|
|
104
|
+
digest = createHash('sha256').update(readFileSync(img.abs)).digest('hex');
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* 읽기 실패 시 'missing' 으로 둔다 */
|
|
108
|
+
}
|
|
109
|
+
parts.push(`${img.filename}:${digest}`);
|
|
110
|
+
}
|
|
111
|
+
return hashOf(parts.join('\0'));
|
|
112
|
+
}
|
package/dist/pull.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `confluence-sync pull <pageId|url> [--out <dir>] [--children]`
|
|
3
|
+
* Confluence 페이지를 읽어 Markdown(.md) 으로 생성한다(역방향).
|
|
4
|
+
* 자식이 있는 페이지는 push 관례를 역으로 적용해 <slug>/README.md 폴더로 펼친다.
|
|
5
|
+
* 첨부는 .md 옆에 내려받고 이미지 링크를 로컬 파일명으로 재작성한다.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { resolve, join, dirname, basename } from 'node:path';
|
|
9
|
+
import { readEnv, requireEnv } from './config.js';
|
|
10
|
+
import { createClient } from './confluence.js';
|
|
11
|
+
import { htmlToMarkdown, codeLanguagesFromStorage } from './html2md.js';
|
|
12
|
+
import { cyan, dim, green, red, yellow } from './colors.js';
|
|
13
|
+
// 첨부 이미지는 항상 <md위치>/attachments/<문서명>/ 하위에 저장한다.
|
|
14
|
+
const ASSETS_DIR = 'attachments';
|
|
15
|
+
function optVal(argv, name) {
|
|
16
|
+
const i = argv.indexOf(name);
|
|
17
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
|
18
|
+
}
|
|
19
|
+
/** URL/숫자/쿼리에서 콘텐츠 ID 추출(page·folder URL 모두) */
|
|
20
|
+
function parseContentId(input) {
|
|
21
|
+
if (/^\d+$/.test(input))
|
|
22
|
+
return input;
|
|
23
|
+
return (input.match(/\/(?:pages|folder)\/(\d+)/)?.[1] ??
|
|
24
|
+
input.match(/[?&]pageId=(\d+)/)?.[1] ??
|
|
25
|
+
null);
|
|
26
|
+
}
|
|
27
|
+
/** 제목 → 파일/폴더명(파일시스템 금지문자 치환, 한글 유지) */
|
|
28
|
+
function slug(title) {
|
|
29
|
+
return title.replace(/[\/\\:*?"<>|]/g, '-').replace(/\s+/g, ' ').trim() || 'untitled';
|
|
30
|
+
}
|
|
31
|
+
/** 페이지 1건을 .md 로 생성(자식 페이지/폴더가 있으면 <slug>/README.md 폴더로). */
|
|
32
|
+
async function pullPage(client, node, destDir, withChildren) {
|
|
33
|
+
const { id, title, html, storage } = node;
|
|
34
|
+
// 페이지도 하위 페이지 + 하위 폴더를 모두 가질 수 있다(폴더가 페이지 밑에 올 수 있음).
|
|
35
|
+
const childFolders = withChildren ? await client.getChildFolders(id) : [];
|
|
36
|
+
const childPages = withChildren ? await client.getChildPages(id) : [];
|
|
37
|
+
const hasChildren = childFolders.length + childPages.length > 0;
|
|
38
|
+
const folder = hasChildren ? join(destDir, slug(title)) : destDir;
|
|
39
|
+
const filePath = hasChildren ? join(folder, 'README.md') : join(destDir, `${slug(title)}.md`);
|
|
40
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
41
|
+
// 첨부는 항상 <md위치>/attachments/<문서명>/이미지 에 저장
|
|
42
|
+
const mdBase = basename(filePath).replace(/\.md$/, '');
|
|
43
|
+
const assetPrefix = `${ASSETS_DIR}/${mdBase}`;
|
|
44
|
+
const referenced = new Set();
|
|
45
|
+
const body = htmlToMarkdown(html, {
|
|
46
|
+
onImage: (f) => referenced.add(f),
|
|
47
|
+
assetPrefix,
|
|
48
|
+
codeLangs: codeLanguagesFromStorage(storage), // 코드블록 언어는 storage 가 진짜
|
|
49
|
+
});
|
|
50
|
+
if (referenced.size) {
|
|
51
|
+
const attachments = await client.listAttachments(id);
|
|
52
|
+
const byName = new Map(attachments.map((a) => [a.filename, a.downloadPath]));
|
|
53
|
+
const saveDir = join(dirname(filePath), assetPrefix);
|
|
54
|
+
for (const name of referenced) {
|
|
55
|
+
const dp = byName.get(name);
|
|
56
|
+
if (!dp) {
|
|
57
|
+
console.error(yellow(` ⚠ 첨부 없음: ${name}`));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
mkdirSync(saveDir, { recursive: true });
|
|
62
|
+
writeFileSync(join(saveDir, name), await client.downloadAttachment(dp));
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
console.error(red(` ✗ 첨부 다운로드 실패 ${name}`) + `\n${e.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
writeFileSync(filePath, `# ${title}\n\n${body}`);
|
|
70
|
+
const imgNote = referenced.size ? `, 이미지 ${referenced.size}` : '';
|
|
71
|
+
console.log(` ${green('+ 생성')} ${filePath} ${dim(`(#${id}${imgNote})`)}`);
|
|
72
|
+
const counts = { pages: 1, folders: 0 };
|
|
73
|
+
for (const f of childFolders)
|
|
74
|
+
add(counts, await pullNode(client, f.id, folder, withChildren));
|
|
75
|
+
for (const c of childPages)
|
|
76
|
+
add(counts, await pullNode(client, c.id, folder, withChildren));
|
|
77
|
+
return counts;
|
|
78
|
+
}
|
|
79
|
+
function add(acc, c) { acc.pages += c.pages; acc.folders += c.folders; }
|
|
80
|
+
/** 노드(페이지/폴더)를 재귀적으로 가져온다. 폴더는 디렉토리(본문 없음)로 만든다. 노드 하나 실패는 스킵. */
|
|
81
|
+
async function pullNode(client, id, destDir, withChildren) {
|
|
82
|
+
try {
|
|
83
|
+
const node = await client.getNode(id);
|
|
84
|
+
if (node.type === 'folder') {
|
|
85
|
+
const dir = join(destDir, slug(node.title));
|
|
86
|
+
mkdirSync(dir, { recursive: true });
|
|
87
|
+
console.log(` ${cyan('📁 폴더')} ${dir} ${dim(`(#${id})`)}`);
|
|
88
|
+
const counts = { pages: 0, folders: 1 };
|
|
89
|
+
if (withChildren) {
|
|
90
|
+
for (const f of await client.getChildFolders(id))
|
|
91
|
+
add(counts, await pullNode(client, f.id, dir, true));
|
|
92
|
+
for (const p of await client.getChildPages(id))
|
|
93
|
+
add(counts, await pullNode(client, p.id, dir, true));
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
console.log(dim(' (하위 내용은 --children 으로 가져옵니다)'));
|
|
97
|
+
}
|
|
98
|
+
return counts;
|
|
99
|
+
}
|
|
100
|
+
// page: getNode 결과(html + storage) 그대로 사용
|
|
101
|
+
return await pullPage(client, node, destDir, withChildren);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
console.error(red(` ✗ 실패 #${id}`) + `\n${e.message}`);
|
|
105
|
+
return { pages: 0, folders: 0 };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export async function runPull(argv) {
|
|
109
|
+
const withChildren = argv.includes('--children');
|
|
110
|
+
const wholeSpace = argv.includes('--space');
|
|
111
|
+
const outDir = resolve(optVal(argv, '--out') ?? process.cwd());
|
|
112
|
+
const env = readEnv();
|
|
113
|
+
requireEnv(env);
|
|
114
|
+
const client = createClient({ baseUrl: env.baseUrl, email: env.email, token: env.token }, { force: false, verify: false });
|
|
115
|
+
// --space: 스페이스 홈페이지(콘텐츠 트리 루트)부터 전체를 재귀적으로 가져옴
|
|
116
|
+
if (wholeSpace) {
|
|
117
|
+
const { homepageId } = await client.getSpaceInfo(env.spaceKey);
|
|
118
|
+
if (!homepageId) {
|
|
119
|
+
console.error(red(`✗ 스페이스 '${env.spaceKey}' 의 홈페이지를 찾을 수 없습니다.`));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
console.log(`${dim('pull:')} space ${cyan(env.spaceKey)} ${dim('→')} ${cyan(outDir)} ${dim('(전체)')}`);
|
|
123
|
+
const { pages, folders } = await pullNode(client, homepageId, outDir, true);
|
|
124
|
+
console.log(`\n${green('완료')} 페이지 ${pages}개${folders ? `, 폴더 ${folders}개` : ''} 생성`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// 위치 인자: ['pull', <pageId|url>, ...]
|
|
128
|
+
const positionals = [];
|
|
129
|
+
for (let i = 0; i < argv.length; i++) {
|
|
130
|
+
if (argv[i] === '--out') {
|
|
131
|
+
i++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (argv[i].startsWith('-'))
|
|
135
|
+
continue;
|
|
136
|
+
positionals.push(argv[i]);
|
|
137
|
+
}
|
|
138
|
+
const target = positionals[1];
|
|
139
|
+
if (!target) {
|
|
140
|
+
console.error(red('✗ 가져올 페이지/폴더를 지정하세요.') + `\n 예) ${cyan('confluence-sync pull <pageId|url> [--out <dir>] [--children]')}\n 스페이스 전체: ${cyan('confluence-sync pull --space')}`);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
const contentId = parseContentId(target);
|
|
144
|
+
if (!contentId) {
|
|
145
|
+
console.error(red(`✗ 콘텐츠 ID 를 인식할 수 없습니다: ${target}`) + '\n 숫자 ID 또는 .../pages/<ID>/... · .../folder/<ID> URL 을 주세요.');
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
console.log(`${dim('pull:')} #${contentId} ${dim('→')} ${cyan(outDir)}${withChildren ? dim(' (+children)') : ''}`);
|
|
149
|
+
const { pages, folders } = await pullNode(client, contentId, outDir, withChildren);
|
|
150
|
+
console.log(`\n${green('완료')} 페이지 ${pages}개${folders ? `, 폴더 ${folders}개` : ''} 생성`);
|
|
151
|
+
}
|