@wonseok-han/confluence-sync 1.3.0 → 1.4.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/README.md CHANGED
@@ -20,7 +20,7 @@ confluence-sync --version
20
20
  confluence-sync --help
21
21
  ```
22
22
 
23
- 공개 npm 패키지라 **토큰이나 `.npmrc` 설정이 필요 없습니다.** (Node 20+)
23
+ 공개 npm 패키지라 **토큰이나 `.npmrc` 설정이 필요 없습니다.** (Node 22.13+)
24
24
 
25
25
  > 짧은 이름 **`csync`** 도 같이 설치됩니다 — 아래 예시의 `confluence-sync` 는 전부 `csync` 로 바꿔 쓸 수 있습니다.
26
26
 
@@ -298,6 +298,15 @@ confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault
298
298
  - **섹션 링크는 표기가 다릅니다** — 표준 마크다운은 슬러그(`문서.md#6-zkp`), Obsidian 은 헤딩 텍스트(`[[문서#6. ZKP]]`)를 씁니다. 대상 문서의 헤딩을 찾아 옮기고, 못 찾으면 섹션 없이 문서 링크로 둡니다. Obsidian 블록 참조(`#^id`)는 마크다운에 대응물이 없어 같은 방식으로 처리합니다.
299
299
  - **같은 문서 섹션**도 마찬가지입니다 — `[§4](#4-버전)` ↔ `[[#4. 버전|§4]]`. 표 안에서 `\|` 로 이스케이프된 별칭도 인식합니다.
300
300
 
301
+ ### 각주와 첨부
302
+
303
+ `--to markdown`은 각주를 위첨자 링크와 작은 제목으로 변환하고, 각주에 연결된 PDF의
304
+ `#page=N` 페이지를 이미지로 표시합니다. 첨부파일은 `attachments/`에 모아 연결합니다.
305
+ PDF 이미지 추출은 패키지에 포함된 PDF.js로 처리하므로 별도 도구 설치가 필요 없습니다.
306
+
307
+ `--to obsidian`은 변환 정보와 원본 PDF가 남아 있는 로컬 변환본에서 각주와 PDF 참조를 복원합니다.
308
+ Confluence에서 다시 내려받은 문서의 복원은 보장하지 않습니다.
309
+
301
310
  ### 보정 (`--fix`)
302
311
 
303
312
  변환기가 개선되기 전에 pull 한 문서에는 옛 결함의 흔적이 남아 있습니다. 스페이스를 통째로 다시 받지 않고 그 흔적만 되돌립니다.
@@ -334,6 +343,8 @@ confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault
334
343
 
335
344
  ## 개발
336
345
 
346
+ `npm test`로 전체 테스트, `npm run test:coverage`로 커버리지를 확인합니다. 테스트는 실제 Confluence에 접속하지 않습니다.
347
+
337
348
  ```bash
338
349
  git clone https://github.com/wonseok-han/confluence-sync.git
339
350
  cd confluence-sync
@@ -0,0 +1,59 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, statSync, readFileSync } from 'node:fs';
3
+ import { resolve, relative, dirname, basename, isAbsolute } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { mapMarkdownText } from './pdf-pages.js';
6
+ /** 출력 트리의 경로와 실제 입력 파일을 연결한다. dry-run에서도 파일 복사 없이 사용한다. */
7
+ export function attachmentMapper(file, base) {
8
+ const copies = new Map(); // base 상대 출력 경로 → 입력 절대경로
9
+ function localPath(raw) {
10
+ if (!raw || raw.startsWith('#') || raw.startsWith('//'))
11
+ return null;
12
+ if (/^file:/i.test(raw))
13
+ return fileURLToPath(raw);
14
+ if (!isAbsolute(raw) && /^[a-z][a-z\d+.-]*:/i.test(raw))
15
+ return null;
16
+ try {
17
+ return resolve(dirname(file), decodeURIComponent(raw));
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ function relocate(raw, wiki = false) {
24
+ const hash = raw.indexOf('#');
25
+ const path = hash < 0 ? raw : raw.slice(0, hash), fragment = hash < 0 ? '' : raw.slice(hash);
26
+ const abs = wiki && !path.startsWith('/') && !path.startsWith('.') && !/^file:/i.test(path)
27
+ ? resolve(base, path) : localPath(path);
28
+ if (!abs || /\.md$/i.test(abs))
29
+ return null;
30
+ // 일반 외부 URL 및 없는 링크는 그대로 남긴다.
31
+ if (!existsSync(abs) || !statSync(abs).isFile())
32
+ return null;
33
+ let rel = relative(base, abs).split('\\').join('/');
34
+ if (!rel.startsWith('attachments/')) {
35
+ const hash = createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 24);
36
+ rel = `attachments/files/${hash}/${basename(abs)}`;
37
+ }
38
+ copies.set(rel, abs);
39
+ const target = wiki ? rel : relative(dirname(file), resolve(base, rel)).split('\\').join('/');
40
+ return target + fragment;
41
+ }
42
+ function rewrite(text) {
43
+ return mapMarkdownText(text, part => {
44
+ const md = part.replace(/(?<!!)(!?)\[([^\]\n]*)\]\((<[^>\n]+>|[^()\s]+)\)/g, (all, bang, label, raw, offset) => {
45
+ if ((part.slice(0, offset).match(/\\+$/)?.[0].length ?? 0) % 2)
46
+ return all;
47
+ const dest = relocate(raw.replace(/^<(.*)>$/, '$1'));
48
+ return dest === null ? all : `${bang}[${label}](<${dest.replace(/</g, '%3C').replace(/>/g, '%3E')}>)`;
49
+ });
50
+ return md.replace(/(!?)\[\[([^\]|\n]+)(?:\|([^\]\n]*))?\]\]/g, (all, bang, target, label, offset) => {
51
+ if ((md.slice(0, offset).match(/\\+$/)?.[0].length ?? 0) % 2)
52
+ return all;
53
+ const dest = relocate(target, true);
54
+ return dest === null ? all : `${bang}[[${dest}${label === undefined ? '' : '|' + label}]]`;
55
+ });
56
+ });
57
+ }
58
+ return { copies, rewrite, source: (abs) => copies.get(relative(base, abs).split('\\').join('/')) ?? abs };
59
+ }
package/dist/convert.js CHANGED
@@ -9,11 +9,14 @@
9
9
  * 스페이스를 통째로 다시 받지 않고도 최신 변환 품질을 얻기 위한 것이다.
10
10
  * 링크는 대상이 트리 안에 실제로 있을 때만 바꾼다(외부 URL·이미지·깨진 링크는 손대지 않는다).
11
11
  */
12
- import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, copyFileSync } from 'node:fs';
13
- import { resolve, relative, dirname, basename, join } from 'node:path';
12
+ import { attachmentMapper } from './attachments.js';
13
+ import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, copyFileSync, realpathSync } from 'node:fs';
14
+ import { resolve, relative, dirname, basename, join, isAbsolute } from 'node:path';
14
15
  import { collectMarkdown, collectAssets, buildVault, vaultResolver } from './docs.js';
15
16
  import { linksToWikilinks, resolveWikilinks, splitFrontmatter } from './obsidian.js';
16
17
  import { buildAnchorIndex } from './anchors.js';
18
+ import { footnotesToLinks, linksToFootnotes } from './footnotes.js';
19
+ import { extractPdfPages, restorePdfReferences, mapMarkdownText } from './pdf-pages.js';
17
20
  import { repairMarkdown, totalFixes } from './repair.js';
18
21
  import { bold, cyan, dim, gray, green, red, yellow } from './colors.js';
19
22
  function optVal(argv, name) {
@@ -27,17 +30,27 @@ catch {
27
30
  return s;
28
31
  } };
29
32
  const posix = (s) => s.split('\\').join('/');
30
- // ![alt](경로) — 본문이 참조하는 로컬 이미지(--out 으로 복사할 때 같이 옮긴다)
31
- const IMAGE = /!\[[^\]]*\]\((<[^>]+>|[^()\s]+)\)/g;
33
+ const isWithin = (base, path) => {
34
+ const rel = relative(base, path);
35
+ return rel === '' || (rel !== '..' && !rel.startsWith('../') && !rel.startsWith('..\\') && !isAbsolute(rel));
36
+ };
37
+ function canonical(path) {
38
+ if (existsSync(path))
39
+ return realpathSync(path);
40
+ const parent = dirname(path);
41
+ return parent === path ? path : join(canonical(parent), basename(path));
42
+ }
43
+ // 이미지·일반 링크의 로컬 첨부(--out 으로 복사할 때 PDF도 같이 옮긴다).
44
+ const ASSET_LINK = /!?\[[^\]]*\]\((<[^>]+>|[^()\s]+)\)/g;
32
45
  /** md 본문이 참조하는 로컬 첨부의 절대경로들(외부 URL 제외, 실제 존재하는 것만). */
33
46
  function referencedAssets(text, fileAbs) {
34
47
  const out = [];
35
- for (const m of text.matchAll(IMAGE)) {
48
+ for (const m of text.matchAll(ASSET_LINK)) {
36
49
  const dest = decode(m[1].replace(/^<(.*)>$/, '$1')).split('#')[0];
37
50
  if (/^(https?:|data:)/i.test(dest))
38
51
  continue;
39
52
  const abs = resolve(dirname(fileAbs), dest);
40
- if (existsSync(abs))
53
+ if (existsSync(abs) && statSync(abs).isFile() && !/\.md$/i.test(abs))
41
54
  out.push(abs);
42
55
  }
43
56
  return out;
@@ -62,7 +75,7 @@ function commonAncestor(paths) {
62
75
  * wikilink 는 vault 어디서든 "이름"으로 찾으므로, 파일명이 트리 안에서 유일할 때만 바꾼다
63
76
  * (겹치면 엉뚱한 노트로 이어질 수 있어 상대 링크를 그대로 둔다).
64
77
  */
65
- function toObsidian(text, fileAbs, baseDir, nameCount, headingOf, selfHeadingOf) {
78
+ function toObsidian(text, fileAbs, baseDir, nameCount, headingOf, selfHeadingOf, source = p => p) {
66
79
  return linksToWikilinks(text, (dest, label) => {
67
80
  if (/^(https?:|mailto:)/i.test(dest))
68
81
  return null; // 외부 링크
@@ -76,6 +89,13 @@ function toObsidian(text, fileAbs, baseDir, nameCount, headingOf, selfHeadingOf)
76
89
  return l && l !== heading ? `[[#${heading}|${l}]]` : `[[#${heading}]]`;
77
90
  }
78
91
  const [pathPart, ...hash] = decode(dest).split('#');
92
+ if (/\.pdf$/i.test(pathPart)) {
93
+ const target = resolve(dirname(fileAbs), pathPart);
94
+ const rel = relative(baseDir, target).split('\\').join('/');
95
+ if (!existsSync(source(target)) || rel.startsWith('../'))
96
+ return null;
97
+ return `[[${rel}${hash.length ? '#' + hash.join('#') : ''}|${label}]]`;
98
+ }
79
99
  if (!/\.md$/i.test(pathPart))
80
100
  return null;
81
101
  const targetAbs = resolve(dirname(fileAbs), pathPart);
@@ -167,7 +187,7 @@ export async function runConvert(argv) {
167
187
  const selected = targets.length
168
188
  ? allMd.filter((f) => targets.some((t) => {
169
189
  const abs = resolve(t);
170
- return f === abs || f.startsWith(abs + '/');
190
+ return isWithin(abs, f);
171
191
  }))
172
192
  : allMd;
173
193
  // base 밖의 .md 를 직접 지목한 경우(예: 이웃 폴더 파일) 는 위 필터에 안 걸린다
@@ -189,7 +209,7 @@ export async function runConvert(argv) {
189
209
  const scope = mdAbs.length === allMd.length
190
210
  ? `${mdAbs.length}건`
191
211
  : `선택 ${mdAbs.length}/${allMd.length}건`;
192
- if (outDir && (outDir === baseDir || outDir.startsWith(baseDir + '/'))) {
212
+ if (outDir && isWithin(canonical(baseDir), canonical(outDir))) {
193
213
  console.error(red(`✗ --out 이 base 안에 있습니다: ${outDir}`) + dim('\n 원본 트리를 덮어쓰지 않도록 base 밖의 경로를 쓰세요.'));
194
214
  process.exit(1);
195
215
  }
@@ -204,7 +224,9 @@ export async function runConvert(argv) {
204
224
  const rel = posix(relative(baseDir, abs));
205
225
  const before = readFileSync(abs, 'utf8');
206
226
  let text = before;
227
+ const sourceAssets = new Set(to === 'obsidian' ? [] : referencedAssets(before, abs));
207
228
  const notes = [];
229
+ const attachments = attachmentMapper(abs, baseDir);
208
230
  // 보정을 먼저 한다 — 이스케이프가 풀려야(\[…\] → […]) 링크 인식이 정확해진다
209
231
  if (fix) {
210
232
  const r = repairMarkdown(text);
@@ -217,22 +239,60 @@ export async function runConvert(argv) {
217
239
  }
218
240
  }
219
241
  if (to === 'obsidian') {
242
+ const restored = restorePdfReferences(text, abs, baseDir);
243
+ restored.assets.forEach(a => sourceAssets.add(a));
244
+ referencedAssets(restored.text, abs).forEach(a => sourceAssets.add(a));
245
+ text = attachments.rewrite(linksToFootnotes(restored.text));
220
246
  // 같은 문서 앵커는 보정(--fix)까지 끝난 **지금 본문**의 헤딩으로 풀어야 맞는다
221
247
  const selfIdx = buildAnchorIndex(splitFrontmatter(text).body);
222
- text = toObsidian(text, abs, baseDir, nameCount, headingOf, (s) => selfIdx.get(s.toLowerCase()));
248
+ text = toObsidian(text, abs, baseDir, nameCount, headingOf, (s) => selfIdx.get(s.toLowerCase()), attachments.source);
249
+ }
250
+ else if (to === 'markdown') {
251
+ const lookup = vaultResolver(rel, vault);
252
+ // 명시된 첨부 경로를 먼저 출력 트리로 옮긴 뒤 wiki 이름을 해석한다.
253
+ text = attachments.rewrite(text);
254
+ text = resolveWikilinks(text, (target, embed) => {
255
+ const imported = resolve(baseDir, target);
256
+ if (attachments.copies.has(posix(relative(baseDir, imported)))) {
257
+ return posix(relative(dirname(abs), imported));
258
+ }
259
+ return lookup(target, embed);
260
+ });
261
+ text = footnotesToLinks(attachments.rewrite(text));
262
+ mapMarkdownText(text, part => {
263
+ if (/\[\[[^\]\n]*\.pdf#page=[^\]\n]*\]\]/i.test(part)) {
264
+ throw new Error(`PDF 위키링크를 해석하지 못했습니다 (${rel}). --base에 PDF가 포함되는지 확인하세요.`);
265
+ }
266
+ return part;
267
+ });
268
+ const pdf = await extractPdfPages(text, abs, baseDir, outDir ?? baseDir, dryRun, attachments.source);
269
+ pdf.assets.forEach(a => sourceAssets.add(a));
270
+ text = pdf.text;
271
+ if (pdf.pages)
272
+ notes.push(dim(`PDF ${pdf.pages}쪽 이미지`));
223
273
  }
224
- else if (to === 'markdown')
225
- text = resolveWikilinks(text, vaultResolver(rel, vault));
226
274
  // 제자리 변환이면 바뀐 것만 쓰지만, --out 이면 대상 전부를 내보낸다(온전한 트리가 나와야 하므로)
227
275
  if (!outDir && text === before)
228
276
  continue;
277
+ if (!dryRun) {
278
+ for (const [assetRel, source] of attachments.copies) {
279
+ const destination = join(outDir ?? baseDir, assetRel);
280
+ if (destination !== source) {
281
+ mkdirSync(dirname(destination), { recursive: true });
282
+ copyFileSync(source, destination);
283
+ }
284
+ copiedAssets.add(assetRel);
285
+ }
286
+ }
229
287
  if (outDir) {
230
288
  const target = join(outDir, rel);
231
289
  if (!dryRun) {
232
290
  mkdirSync(dirname(target), { recursive: true });
233
291
  writeFileSync(target, text);
234
- // 이미지가 따라가지 않으면 링크가 깨진다 — 참조된 첨부를 같은 상대 위치로 복사
235
- for (const asset of referencedAssets(text, abs)) {
292
+ // 참조된 첨부를 같은 상대 위치로 복사한다.
293
+ for (const asset of new Set([...sourceAssets, ...referencedAssets(text, abs)])) {
294
+ if ([...attachments.copies.values()].includes(asset))
295
+ continue;
236
296
  const arel = posix(relative(baseDir, asset));
237
297
  if (arel.startsWith('..') || copiedAssets.has(arel))
238
298
  continue; // base 밖 첨부는 두고 온다
package/dist/docs.js CHANGED
@@ -219,7 +219,7 @@ export function resolveSelection(rels, positionals, baseDir) {
219
219
  const set = new Set();
220
220
  for (const p of positionals) {
221
221
  const abs = resolve(baseDir, p);
222
- const relToBase = relative(baseDir, abs);
222
+ const relToBase = relative(baseDir, abs).split('\\').join('/');
223
223
  if (relToBase.startsWith('..')) {
224
224
  console.error(` ⚠ base 밖 경로 무시: ${p}`);
225
225
  continue;
@@ -0,0 +1,110 @@
1
+ import MarkdownIt from 'markdown-it';
2
+ import footnote from 'markdown-it-footnote';
3
+ import { collectHeadings } from './anchors.js';
4
+ import { mapMarkdownText, replaceGeneratedBlocks } from './pdf-pages.js';
5
+ /** 각주 확장을 일반 제목 앵커 + 위첨자 Markdown 링크로 내보낸다. */
6
+ export function footnotesToLinks(source) {
7
+ if (!source.includes('[^'))
8
+ return source;
9
+ const md = new MarkdownIt({ html: true }).use(footnote);
10
+ const definitions = [];
11
+ const lines = source.split('\n');
12
+ const offsets = [0];
13
+ for (const line of lines)
14
+ offsets.push(offsets[offsets.length - 1] + line.length + 1);
15
+ const defRule = md.block.ruler.getRules('').find((r) => r.name === 'footnote_def');
16
+ md.block.ruler.at('footnote_def', (state, start, end, silent) => {
17
+ const ok = defRule(state, start, end, silent);
18
+ if (ok && !silent) {
19
+ const first = lines[start].match(/^ {0,3}\[\^([^\]]+)\]:\s*(.*)$/);
20
+ if (first)
21
+ definitions.push({
22
+ start: offsets[start], end: Math.min(offsets[state.line], source.length), label: first[1],
23
+ body: [first[2], ...lines.slice(start + 1, state.line).map((l) => l.replace(/^(?: {4}|\t)/, ''))].join('\n').trimEnd(),
24
+ });
25
+ }
26
+ return ok;
27
+ }, { alt: ['paragraph', 'reference'] });
28
+ const env = {};
29
+ const tokens = md.parse(source, env);
30
+ if (!definitions.length)
31
+ return source;
32
+ const used = new Set(collectHeadings(source).map((h) => h.slug));
33
+ const labels = new Set(definitions.map((d) => d.label));
34
+ const notes = new Map();
35
+ const edits = [];
36
+ const refRule = md.inline.ruler.getRules('').find((r) => r.name === 'footnote_ref');
37
+ let localEdits = [];
38
+ md.inline.ruler.at('footnote_ref', (state, silent) => {
39
+ const start = state.pos;
40
+ const ok = refRule(state, silent);
41
+ if (ok && !silent) {
42
+ const label = state.src.slice(start + 2, state.pos - 1);
43
+ if (!labels.has(label))
44
+ return ok; // 지원 범위 밖의 중첩 정의는 원문으로 남긴다.
45
+ let note = notes.get(label);
46
+ if (!note) {
47
+ const number = notes.size + 1;
48
+ let heading = `각주 ${number}`, anchor = `각주-${number}`, suffix = 0;
49
+ while (used.has(anchor)) {
50
+ heading = `각주 ${number}-${++suffix}`;
51
+ anchor = `각주-${number}-${suffix}`;
52
+ }
53
+ used.add(anchor);
54
+ note = { number, heading, anchor };
55
+ notes.set(label, note);
56
+ }
57
+ localEdits.push({ start, end: state.pos, text: `<sup>[${note.number}](#${note.anchor})</sup>` });
58
+ }
59
+ return ok;
60
+ });
61
+ // 인라인 각주(^[...])는 이 변환의 대상이 아니다.
62
+ md.inline.ruler.disable('footnote_inline');
63
+ const replaceRefs = (text) => {
64
+ localEdits = [];
65
+ md.inline.parse(text, md, env, []);
66
+ for (const e of localEdits.sort((a, b) => b.start - a.start))
67
+ text = text.slice(0, e.start) + e.text + text.slice(e.end);
68
+ return text;
69
+ };
70
+ // 코드블록과 정의를 제외한 원문 구간을 그대로 보존한다.
71
+ const excluded = [
72
+ ...definitions.map((d) => ({ start: d.start, end: d.end, definition: true })),
73
+ ...tokens.filter((t) => (t.type === 'fence' || t.type === 'code_block' || t.type === 'html_block') && t.map)
74
+ .map((t) => ({ start: offsets[t.map[0]], end: Math.min(offsets[t.map[1]], source.length), definition: false })),
75
+ ].sort((a, b) => a.start - b.start);
76
+ let cursor = 0;
77
+ for (const span of excluded) {
78
+ if (span.start < cursor)
79
+ continue;
80
+ edits.push({ start: cursor, end: span.start, text: replaceRefs(source.slice(cursor, span.start)) });
81
+ if (span.definition)
82
+ edits.push({ start: span.start, end: span.end, text: '' });
83
+ cursor = span.end;
84
+ }
85
+ edits.push({ start: cursor, end: source.length, text: replaceRefs(source.slice(cursor)) });
86
+ // 참조하지 않은 정의도 버리지 않는다.
87
+ for (const d of definitions)
88
+ if (!notes.has(d.label))
89
+ replaceRefs(`[^${d.label}]`);
90
+ let result = source;
91
+ for (const e of edits.sort((a, b) => b.start - a.start))
92
+ result = result.slice(0, e.start) + e.text + result.slice(e.end);
93
+ const bodies = new Map(definitions.map((d) => [d.label, d.body]));
94
+ return result.trimEnd() + '\n\n' + [...notes].map(([label, note]) => `<!-- csync-footnote:v1 ${Buffer.from(JSON.stringify({ label, anchor: note.anchor })).toString('base64')} -->\n###### ${note.heading}\n\n${replaceRefs(bodies.get(label) ?? '')}\n<!-- /csync-footnote -->`).join('\n\n') + '\n';
95
+ }
96
+ /** 생성 표식이 있는 각주만 복원한다. 설명의 사용자 수정은 유지한다. */
97
+ export function linksToFootnotes(source) {
98
+ const refs = new Map();
99
+ const definitions = [];
100
+ const text = replaceGeneratedBlocks(source, /<!-- csync-footnote:v1 ([A-Za-z0-9+/=]+) -->\n#{1,6} [^\n]+\n\n([\s\S]*?)\n<!-- \/csync-footnote -->/g, (_all, encoded, body) => {
101
+ const { label, anchor } = JSON.parse(Buffer.from(encoded, 'base64').toString());
102
+ refs.set(anchor, label);
103
+ definitions.push(`[^${label}]: ${body.trimEnd().split('\n').join('\n ')}`);
104
+ return '';
105
+ });
106
+ if (!refs.size)
107
+ return source;
108
+ const replace = (s) => mapMarkdownText(s, p => p.replace(/<sup>\[\d+\]\(#([^\s)]+)\)<\/sup>/g, (all, anchor) => refs.has(decodeURIComponent(anchor)) ? `[^${refs.get(decodeURIComponent(anchor))}]` : all));
109
+ return replace(text).trimEnd() + '\n\n' + definitions.map(replace).join('\n\n') + '\n';
110
+ }
package/dist/help.js CHANGED
@@ -63,6 +63,9 @@ ${h('pull(역방향) 옵션:')}
63
63
  ${h('convert(이미 받아둔 .md 손보기) 옵션:')} ${dim('Confluence 호출·인증 없음')}
64
64
  ${o('--to obsidian')} 상대 .md 링크 → [[wikilink]]
65
65
  ${o('--to markdown')} [[wikilink]] · ![[embed]] → 상대 .md 링크 (되돌리기)
66
+ 각주 → 위첨자 링크·제목 앵커, PDF #page=N → 해당 쪽 이미지
67
+ --to obsidian은 변환 표식으로 각주·PDF 참조를 복원
68
+ 절대경로·file://·../ 로컬 첨부는 출력 트리에 복사하고 링크 변경
66
69
  ${o('--fix')} 옛 pull 결과 보정 — 코드블록 언어(java→plaintext)·CSS 잔해·
67
70
  불필요한 \\ 이스케이프·리스트 사이 빈 줄
68
71
  ${o('--base <dir>')} 링크를 해석할 문서 트리 루트(기본: 준 경로들의 공통 상위 폴더)
package/dist/markdown.js CHANGED
@@ -57,7 +57,7 @@ function resolveInternalLink(href) {
57
57
  const [pathPart, ...rest] = href.split('#');
58
58
  if (!pathPart || !/\.md$/i.test(pathPart))
59
59
  return null;
60
- const rel = relative(baseDir, resolve(baseDir, ctx.fileDir, decodePath(pathPart)));
60
+ const rel = relative(baseDir, resolve(baseDir, ctx.fileDir, decodePath(pathPart))).split('\\').join('/');
61
61
  const title = ctx.titleIndex[rel];
62
62
  if (!title)
63
63
  return null;
@@ -77,6 +77,13 @@ md.renderer.rules.heading_open = (tokens, idx, opts, _env, self) => {
77
77
  md.renderer.rules.fence = (tokens, idx) => {
78
78
  const t = tokens[idx];
79
79
  const lang = (t.info || '').trim().split(/\s+/)[0];
80
+ // 별도 호스팅한 PDF.js 등 웹 뷰어를 Confluence iFrame 매크로로 표시.
81
+ if (lang === 'confluence-iframe') {
82
+ const url = t.content.trim();
83
+ if (!/^https?:\/\/[^\s<>]+$/i.test(url))
84
+ throw new Error('confluence-iframe에는 HTTP(S) URL 한 줄이 필요합니다.');
85
+ return `<ac:structured-macro ac:name="iframe"><ac:parameter ac:name="url">${escapeXml(url)}</ac:parameter><ac:parameter ac:name="width">100%</ac:parameter><ac:parameter ac:name="height">900</ac:parameter></ac:structured-macro>\n`;
86
+ }
80
87
  const safe = t.content.split(']]>').join(']]]]><![CDATA[>');
81
88
  const langParam = lang ? `<ac:parameter ac:name="language">${lang}</ac:parameter>` : '';
82
89
  return `<ac:structured-macro ac:name="code">${langParam}<ac:plain-text-body><![CDATA[${safe}]]></ac:plain-text-body></ac:structured-macro>\n`;
package/dist/obsidian.js CHANGED
@@ -73,7 +73,9 @@ export function resolveWikilinks(md, resolve) {
73
73
  return whole;
74
74
  // Obsidian 은 섹션을 헤딩 텍스트로 가리키지만(`#1. 신뢰 — Trust Registry`)
75
75
  // 표준 마크다운은 슬러그를 쓴다. 블록 참조(`#^id`)는 대응물이 없어 섹션을 떼어 낸다.
76
- const slug = w.fragment ? wikilinkAnchorToSlug(w.fragment) : null;
76
+ const slug = w.fragment
77
+ ? (/\.pdf$/i.test(w.target) ? w.fragment : wikilinkAnchorToSlug(w.fragment))
78
+ : null;
77
79
  // [[#헤딩]] — 대상이 없으면 "이 문서의 이 섹션"이다. vault 조회 없이 앵커 링크가 된다.
78
80
  if (!w.target) {
79
81
  if (!slug)
@@ -85,7 +87,7 @@ export function resolveWikilinks(md, resolve) {
85
87
  return whole; // 해석 실패 → 원문 보존
86
88
  const label = w.alias ?? w.target;
87
89
  if (w.embed)
88
- return `![${label}](${dest(found)})`;
90
+ return `![${label}](${dest(found + (/\.pdf$/i.test(w.target) && slug ? `#${slug}` : ''))})`;
89
91
  return `[${label}](${dest(found + (slug ? `#${slug}` : ''))})`;
90
92
  }));
91
93
  }
@@ -0,0 +1,148 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
3
+ import { resolve, relative, dirname } from 'node:path';
4
+ import { renderPdfPages } from './pdf-renderer.js';
5
+ import MarkdownIt from 'markdown-it';
6
+ import { collectHeadings } from './anchors.js';
7
+ const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64');
8
+ const decode = (value) => JSON.parse(Buffer.from(value, 'base64').toString());
9
+ const parser = new MarkdownIt({ html: true });
10
+ /** 소스 위치를 유지하면서 코드·HTML 블록을 제외한 일반 텍스트를 처리한다. */
11
+ export function mapMarkdownText(text, fn) {
12
+ const lines = text.split('\n'), offsets = [0];
13
+ for (const line of lines)
14
+ offsets.push(offsets[offsets.length - 1] + line.length + 1);
15
+ const spans = parser.parse(text, {}).filter(t => ['fence', 'code_block', 'html_block'].includes(t.type) && t.map)
16
+ .map(t => [offsets[t.map[0]], Math.min(offsets[t.map[1]], text.length)]);
17
+ let cursor = 0, result = '';
18
+ const prose = (s) => protectInline(s, fn);
19
+ for (const [start, end] of spans) {
20
+ if (start < cursor)
21
+ continue;
22
+ result += prose(text.slice(cursor, start)) + text.slice(start, end);
23
+ cursor = end;
24
+ }
25
+ return result + prose(text.slice(cursor));
26
+ }
27
+ function protectInline(text, fn) {
28
+ const re = /(`+)([\s\S]*?)(?<!`)\1(?!`)/g;
29
+ let cursor = 0, out = '';
30
+ for (const m of text.matchAll(re)) {
31
+ out += fn(text.slice(cursor, m.index)) + m[0];
32
+ cursor = m.index + m[0].length;
33
+ }
34
+ return out + fn(text.slice(cursor));
35
+ }
36
+ const REF = /\[([^\]\n]*)\]\(#[^\s)]+\)<!-- csync-pdf:v1 ([A-Za-z0-9+/=]+) -->/g;
37
+ const INLINE_REF = /!\[([^\]\n]*)\]\((?:<[^>\n]+>|[^()\s]+)\)<!-- csync-pdf-inline:v1 ([A-Za-z0-9+/=]+) -->/g;
38
+ const GALLERY = /\n*<!-- csync-pdf-pages:v1 -->\n[\s\S]*?<!-- \/csync-pdf-pages -->\n*/g;
39
+ /** 자동 생성한 참조만 복원한다. 일반 이미지·제목은 유지한다. */
40
+ export function restorePdfReferences(text, file, base) {
41
+ const assets = [];
42
+ // 매크로 표식은 코드 예제에서 복원하지 않도록 실제 HTML 토큰만 검사한다.
43
+ const restore = (_all, currentLabel, data) => {
44
+ const ref = decode(data);
45
+ if (typeof ref.path !== 'string' || !/\.pdf$/i.test(ref.path) || !Number.isSafeInteger(ref.page) || ref.page < 1) {
46
+ throw new Error('PDF 역변환 정보의 경로 또는 쪽 번호가 올바르지 않습니다.');
47
+ }
48
+ const abs = resolve(dirname(file), ref.path);
49
+ requireLocal(abs, base);
50
+ if (!existsSync(abs))
51
+ throw new Error(`역변환에 필요한 원본 PDF가 없습니다: ${abs}`);
52
+ assets.push(abs);
53
+ const path = relative(base, abs).split('\\').join('/');
54
+ return `${ref.embed ? '!' : ''}[[${path}#page=${ref.page}|${currentLabel || ref.label}]]`;
55
+ };
56
+ const restored = replaceGeneratedBlocks(replaceGeneratedBlocks(text, INLINE_REF, restore), REF, restore);
57
+ return { text: replaceGeneratedBlocks(restored, GALLERY, () => '\n\n'), assets: [...new Set(assets)], pages: 0 };
58
+ }
59
+ /** 생성 표식을 처리하되 fenced/indented code는 그대로 보존한다. */
60
+ export function replaceGeneratedBlocks(text, re, fn) {
61
+ const lines = text.split('\n'), offsets = [0];
62
+ for (const line of lines)
63
+ offsets.push(offsets[offsets.length - 1] + line.length + 1);
64
+ const spans = parser.parse(text, {}).filter(t => ['fence', 'code_block'].includes(t.type) && t.map)
65
+ .map(t => [offsets[t.map[0]], Math.min(offsets[t.map[1]], text.length)]);
66
+ for (const m of text.matchAll(/(`+)([\s\S]*?)(?<!`)\1(?!`)/g))
67
+ spans.push([m.index, m.index + m[0].length]);
68
+ return text.replace(re, (...args) => {
69
+ const offset = args[args.length - 2];
70
+ const markerOffset = offset + args[0].search(/\S/);
71
+ return spans.some(([s, e]) => markerOffset >= s && markerOffset < e) ? args[0] : fn(...args);
72
+ });
73
+ }
74
+ function requireLocal(abs, base) {
75
+ const rel = relative(realpathSync(base), existsSync(abs) ? realpathSync(abs) : abs);
76
+ if (rel === '..' || rel.startsWith('../') || rel.startsWith('..\\'))
77
+ throw new Error(`PDF는 --base 안에 있어야 합니다: ${abs}`);
78
+ }
79
+ export async function extractPdfPages(text, file, base, output, dryRun, source = p => p) {
80
+ const assets = new Set(), headings = new Set(collectHeadings(text).map(h => h.slug));
81
+ const pages = new Map();
82
+ const hashes = new Map();
83
+ const jobs = [];
84
+ const link = /(?<!!)(!?)\[([^\]\n]*)\]\((<[^>\n]+>|[^()\s]+)\)/g;
85
+ const convertLinks = (input, inline) => mapMarkdownText(input, part => part.replace(link, (all, bang, label, raw, offset) => {
86
+ // 홀수 개의 백슬래시로 이스케이프한 링크는 예제 텍스트다.
87
+ if ((part.slice(0, offset).match(/\\+$/)?.[0].length ?? 0) % 2)
88
+ return all;
89
+ let dest;
90
+ try {
91
+ dest = decodeURIComponent(raw.replace(/^<(.*)>$/, '$1'));
92
+ }
93
+ catch {
94
+ return all;
95
+ }
96
+ if (/^[a-z][a-z\d+.-]*:/i.test(dest))
97
+ return all;
98
+ const m = dest.match(/^(.*\.pdf)#page=(\d+)$/i);
99
+ if (!m)
100
+ return all;
101
+ const local = resolve(dirname(file), m[1]);
102
+ const abs = source(local);
103
+ if (abs === local)
104
+ requireLocal(local, base);
105
+ if (!existsSync(abs))
106
+ throw new Error(`PDF 파일을 찾을 수 없습니다: ${abs}`);
107
+ const page = Number(m[2]);
108
+ if (!Number.isSafeInteger(page) || page < 1)
109
+ throw new Error(`잘못된 PDF 쪽 번호: ${dest}`);
110
+ assets.add(abs);
111
+ let hash = hashes.get(abs);
112
+ if (!hash) {
113
+ hash = createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 24);
114
+ hashes.set(abs, hash);
115
+ }
116
+ const key = `${hash}-${page}`;
117
+ let entry = pages.get(key);
118
+ if (!entry) {
119
+ let heading = `PDF 원문 ${pages.size + 1} - ${page}쪽`;
120
+ let anchor = `pdf-원문-${pages.size + 1}---${page}쪽`, suffix = 0;
121
+ const initialHeading = heading, initialAnchor = anchor;
122
+ while (headings.has(anchor)) {
123
+ heading = `${initialHeading}-${++suffix}`;
124
+ anchor = `${initialAnchor}-${suffix}`;
125
+ }
126
+ headings.add(anchor);
127
+ const assetRel = `attachments/pdf-pages/pdfjs-${key}.png`;
128
+ const target = resolve(output, assetRel);
129
+ jobs.push({ file: abs, page, target });
130
+ const outputFile = resolve(output, relative(base, file));
131
+ const image = relative(dirname(outputFile), target).split('\\').join('/');
132
+ entry = { heading, anchor, image, gallery: false };
133
+ pages.set(key, entry);
134
+ }
135
+ const ref = { path: m[1], page, label, embed: bang === '!' };
136
+ if (inline)
137
+ return `![${label || `PDF ${page}쪽`}](<${entry.image}>)<!-- csync-pdf-inline:v1 ${encode(ref)} -->`;
138
+ entry.gallery = true;
139
+ return `[${label || `PDF ${page}쪽`}](#${entry.anchor})<!-- csync-pdf:v1 ${encode(ref)} -->`;
140
+ }));
141
+ const withInlineImages = replaceGeneratedBlocks(text, /<!-- csync-footnote:v1 [A-Za-z0-9+/=]+ -->\n[\s\S]*?<!-- \/csync-footnote -->/g, block => convertLinks(block, true));
142
+ const converted = convertLinks(withInlineImages, false);
143
+ if (!pages.size)
144
+ return { text, assets: restorePdfReferences(text, file, base).assets, pages: 0 };
145
+ await renderPdfPages(jobs, dryRun);
146
+ const gallery = [...pages.values()].filter(p => p.gallery).map(p => `### ${p.heading}\n\n![${p.heading}](<${p.image}>)`).join('\n\n');
147
+ return { text: converted.trimEnd() + (gallery ? `\n\n<!-- csync-pdf-pages:v1 -->\n${gallery}\n<!-- /csync-pdf-pages -->\n` : '\n'), assets: [...assets], pages: pages.size };
148
+ }
@@ -0,0 +1,63 @@
1
+ import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join } from 'node:path';
4
+ /** npm으로 설치한 렌더러와 글꼴/CMap/WASM 리소스만 사용한다. */
5
+ export async function renderPdfPages(jobs, dryRun) {
6
+ if (!jobs.length)
7
+ return;
8
+ const { getDocument, VerbosityLevel } = await import('pdfjs-dist/legacy/build/pdf.mjs');
9
+ const { createCanvas } = await import('@napi-rs/canvas');
10
+ const require = createRequire(import.meta.url);
11
+ const root = dirname(require.resolve('pdfjs-dist/package.json'));
12
+ // PDF.js는 Windows에서도 '/'로 끝나는 리소스 경로를 요구한다.
13
+ const resourceDir = (name) => join(root, name).split('\\').join('/') + '/';
14
+ const files = new Map();
15
+ for (const job of jobs) {
16
+ const group = files.get(job.file) ?? [];
17
+ group.push(job);
18
+ files.set(job.file, group);
19
+ }
20
+ for (const [file, pages] of files) {
21
+ const task = getDocument({
22
+ data: new Uint8Array(readFileSync(file)),
23
+ cMapUrl: resourceDir('cmaps'),
24
+ cMapPacked: true,
25
+ standardFontDataUrl: resourceDir('standard_fonts'),
26
+ wasmUrl: resourceDir('wasm'),
27
+ useSystemFonts: false,
28
+ verbosity: VerbosityLevel.ERRORS,
29
+ });
30
+ try {
31
+ const pdf = await task.promise;
32
+ for (const { page } of pages) {
33
+ if (page > pdf.numPages)
34
+ throw new Error(`PDF 쪽 범위 초과: ${page} (총 ${pdf.numPages}쪽)`);
35
+ }
36
+ if (dryRun)
37
+ continue;
38
+ for (const { page: pageNumber, target } of pages) {
39
+ if (existsSync(target))
40
+ continue;
41
+ const page = await pdf.getPage(pageNumber);
42
+ const viewport = page.getViewport({ scale: 160 / 72 });
43
+ const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
44
+ try {
45
+ await page.render({ canvas: canvas, viewport }).promise;
46
+ const png = await canvas.encode('png');
47
+ mkdirSync(dirname(target), { recursive: true });
48
+ writeFileSync(target, png);
49
+ }
50
+ finally {
51
+ page.cleanup();
52
+ canvas.width = canvas.height = 1;
53
+ }
54
+ }
55
+ }
56
+ catch (error) {
57
+ throw new Error(`PDF 이미지 변환 실패 (${file}): ${error.message}`);
58
+ }
59
+ finally {
60
+ await task.destroy();
61
+ }
62
+ }
63
+ }
package/dist/sync.js CHANGED
@@ -105,7 +105,7 @@ async function main() {
105
105
  if (!DRY_RUN && !LIST)
106
106
  requireEnv(env); // list/dry 는 Confluence 호출이 없어 인증 불필요
107
107
  const ignorer = buildIgnorer(BASE_DIR, EXCLUDES);
108
- const collected = collectMarkdown(BASE_DIR).map((f) => relative(BASE_DIR, f));
108
+ const collected = collectMarkdown(BASE_DIR).map((f) => relative(BASE_DIR, f).split('\\').join('/'));
109
109
  const allRel = collected.filter((r) => !ignorer.ignores(r)); // 제외된 문서는 모든 단계에서 빠짐
110
110
  const ignoredCount = collected.length - allRel.length;
111
111
  const folderIndex = buildFolderIndex(allRel);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonseok-han/confluence-sync",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "description": "Confluence Cloud ↔ Markdown 양방향 동기화 CLI — 마크다운을 Confluence 페이지로 발행(push)하고, Confluence 페이지·폴더·스페이스를 마크다운으로 가져옵니다(pull).",
6
6
  "keywords": [
@@ -23,7 +23,7 @@
23
23
  "url": "https://github.com/wonseok-han/confluence-sync/issues"
24
24
  },
25
25
  "engines": {
26
- "node": ">=20"
26
+ "node": ">=22.13.0"
27
27
  },
28
28
  "bin": {
29
29
  "confluence-sync": "dist/sync.js",
@@ -36,23 +36,29 @@
36
36
  ],
37
37
  "scripts": {
38
38
  "build": "tsc",
39
+ "test": "npm run build && node --test tests/*.test.mjs",
39
40
  "prepublishOnly": "npm run build",
40
41
  "sync": "tsx src/sync.ts",
41
42
  "sync:dry": "tsx src/sync.ts --dry-run",
42
43
  "sync:rebuild": "tsx src/sync.ts --rebuild",
43
- "list": "tsx src/sync.ts --list"
44
+ "list": "tsx src/sync.ts --list",
45
+ "test:coverage": "npm run build && node --test --experimental-test-coverage --test-coverage-include=\"dist/**/*.js\" tests/*.test.mjs"
44
46
  },
45
47
  "publishConfig": {
46
48
  "access": "public"
47
49
  },
48
50
  "dependencies": {
51
+ "@napi-rs/canvas": "^1.0.9",
49
52
  "dotenv": "^16.4.5",
50
53
  "ignore": "^7.0.5",
51
54
  "markdown-it": "^14.1.0",
55
+ "markdown-it-footnote": "^4.0.0",
56
+ "pdfjs-dist": "^6.3.289",
52
57
  "turndown": "^7.2.4"
53
58
  },
54
59
  "devDependencies": {
55
60
  "@types/markdown-it": "^14.1.2",
61
+ "@types/markdown-it-footnote": "^3.0.4",
56
62
  "@types/node": "^22.0.0",
57
63
  "@types/turndown": "^5.0.6",
58
64
  "tsx": "^4.19.0",