@wonseok-han/confluence-sync 1.3.0 → 1.5.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
 
@@ -130,6 +130,7 @@ docs/ → [CONFLUENCE_PARENT_ID 또는 공간 최상위]
130
130
 
131
131
  - 페이지 제목은 **frontmatter `title` → 첫 `# 제목` → 파일명** 순으로 정해지고, 본문은 storage format 으로 변환됩니다.
132
132
  - 코드블록 → **code 매크로**, 표·리스트·헤딩은 그대로.
133
+ - `mermaid` 코드블록 뒤에는 **Mermaid diagram** 매크로를 자동 삽입합니다. Confluence에 [Atlassian Labs Mermaid Diagrams Viewer](https://marketplace.atlassian.com/apps/1232887/mermaid-diagrams-viewer)가 설치되어 있어야 하며, 원본 코드도 함께 표시됩니다. 자동 연결은 등장 순서를 사용하므로 페이지의 모든 Mermaid 코드는 `mermaid` 코드블록으로 작성하세요.
133
134
  - **내부 `.md` 링크**와 **`[[wikilink]]`** → Confluence 페이지 링크로 자동 변환.
134
135
  - **섹션 링크** `[§1 개요](#1-개요)` · `[§6](./04-구현체.md#6-zkp)` · `[[문서#헤딩 텍스트]]` → Confluence 앵커 링크. [아래 참조](#섹션-링크-앵커)
135
136
  - **로컬 이미지**와 **`![[embed]]`** → 페이지 첨부로 업로드 후 참조 (같은 파일명은 새 버전으로 갱신). 외부 URL 이미지는 그대로.
@@ -298,6 +299,15 @@ confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault
298
299
  - **섹션 링크는 표기가 다릅니다** — 표준 마크다운은 슬러그(`문서.md#6-zkp`), Obsidian 은 헤딩 텍스트(`[[문서#6. ZKP]]`)를 씁니다. 대상 문서의 헤딩을 찾아 옮기고, 못 찾으면 섹션 없이 문서 링크로 둡니다. Obsidian 블록 참조(`#^id`)는 마크다운에 대응물이 없어 같은 방식으로 처리합니다.
299
300
  - **같은 문서 섹션**도 마찬가지입니다 — `[§4](#4-버전)` ↔ `[[#4. 버전|§4]]`. 표 안에서 `\|` 로 이스케이프된 별칭도 인식합니다.
300
301
 
302
+ ### 각주와 첨부
303
+
304
+ `--to markdown`은 각주를 위첨자 링크와 작은 제목으로 변환하고, 각주에 연결된 PDF의
305
+ `#page=N` 페이지를 이미지로 표시합니다. 첨부파일은 `attachments/`에 모아 연결합니다.
306
+ PDF 이미지 추출은 패키지에 포함된 PDF.js로 처리하므로 별도 도구 설치가 필요 없습니다.
307
+
308
+ `--to obsidian`은 변환 정보와 원본 PDF가 남아 있는 로컬 변환본에서 각주와 PDF 참조를 복원합니다.
309
+ Confluence에서 다시 내려받은 문서의 복원은 보장하지 않습니다.
310
+
301
311
  ### 보정 (`--fix`)
302
312
 
303
313
  변환기가 개선되기 전에 pull 한 문서에는 옛 결함의 흔적이 남아 있습니다. 스페이스를 통째로 다시 받지 않고 그 흔적만 되돌립니다.
@@ -334,6 +344,8 @@ confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault
334
344
 
335
345
  ## 개발
336
346
 
347
+ `npm test`로 전체 테스트, `npm run test:coverage`로 커버리지를 확인합니다. 테스트는 실제 Confluence에 접속하지 않습니다.
348
+
337
349
  ```bash
338
350
  git clone https://github.com/wonseok-han/confluence-sync.git
339
351
  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
@@ -11,6 +11,7 @@ import { createHash } from 'node:crypto';
11
11
  import MarkdownIt from 'markdown-it';
12
12
  import { resolveWikilinks } from './obsidian.js';
13
13
  import { decodeAnchor, anchorMacro } from './anchors.js';
14
+ import { mermaidMacro } from './mermaid.js';
14
15
  export const escapeXml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
15
16
  export const hashOf = (s) => createHash('sha256').update(s).digest('hex');
16
17
  /** markdown-it 이 퍼센트 인코딩한 링크/이미지 경로를 실제 경로로 디코드(실패 시 원본). */
@@ -25,7 +26,7 @@ const decodePath = (s) => {
25
26
  const md = new MarkdownIt({ html: true, linkify: true, breaks: false });
26
27
  const emptyCtx = () => ({
27
28
  fileDir: '', titleIndex: {}, anchorIndex: {}, selfRel: '', selfTitle: '',
28
- anchorNames: new Set(), bodySlugs: [], headingSeq: 0, anchorsPlaced: 0,
29
+ anchorNames: new Set(), bodySlugs: [], headingSeq: 0, mermaidSeq: 0, anchorsPlaced: 0,
29
30
  images: [], internalLinks: 0, anchorLinks: 0, deadAnchors: [], linkStack: [], linkedTitles: new Set(),
30
31
  });
31
32
  // 렌더 규칙은 md 인스턴스에 한 번만 등록되므로, 현재 변환 컨텍스트를 모듈 변수로 공유한다.
@@ -57,7 +58,7 @@ function resolveInternalLink(href) {
57
58
  const [pathPart, ...rest] = href.split('#');
58
59
  if (!pathPart || !/\.md$/i.test(pathPart))
59
60
  return null;
60
- const rel = relative(baseDir, resolve(baseDir, ctx.fileDir, decodePath(pathPart)));
61
+ const rel = relative(baseDir, resolve(baseDir, ctx.fileDir, decodePath(pathPart))).split('\\').join('/');
61
62
  const title = ctx.titleIndex[rel];
62
63
  if (!title)
63
64
  return null;
@@ -76,10 +77,19 @@ md.renderer.rules.heading_open = (tokens, idx, opts, _env, self) => {
76
77
  };
77
78
  md.renderer.rules.fence = (tokens, idx) => {
78
79
  const t = tokens[idx];
79
- const lang = (t.info || '').trim().split(/\s+/)[0];
80
+ const infoLang = (t.info || '').trim().split(/\s+/)[0];
81
+ const lang = infoLang.toLowerCase() === 'mermaid' ? 'mermaid' : infoLang;
82
+ // 별도 호스팅한 PDF.js 등 웹 뷰어를 Confluence iFrame 매크로로 표시.
83
+ if (lang === 'confluence-iframe') {
84
+ const url = t.content.trim();
85
+ if (!/^https?:\/\/[^\s<>]+$/i.test(url))
86
+ throw new Error('confluence-iframe에는 HTTP(S) URL 한 줄이 필요합니다.');
87
+ 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`;
88
+ }
80
89
  const safe = t.content.split(']]>').join(']]]]><![CDATA[>');
81
- const langParam = lang ? `<ac:parameter ac:name="language">${lang}</ac:parameter>` : '';
82
- return `<ac:structured-macro ac:name="code">${langParam}<ac:plain-text-body><![CDATA[${safe}]]></ac:plain-text-body></ac:structured-macro>\n`;
90
+ const langParam = lang ? `<ac:parameter ac:name="language">${escapeXml(lang)}</ac:parameter>` : '';
91
+ const code = `<ac:structured-macro ac:name="code">${langParam}<ac:plain-text-body><![CDATA[${safe}]]></ac:plain-text-body></ac:structured-macro>\n`;
92
+ return code + (lang === 'mermaid' ? mermaidMacro(ctx.selfRel, ctx.mermaidSeq++) : '');
83
93
  };
84
94
  md.renderer.rules.link_open = (tokens, idx, opts, _env, self) => {
85
95
  const href = tokens[idx].attrGet('href') || '';
@@ -0,0 +1,13 @@
1
+ import { createHash } from 'node:crypto';
2
+ // Atlassian Labs Mermaid Diagrams Viewer (Marketplace production deployment).
3
+ // Confirmed against the installed macro's editor/clipboard attributes.
4
+ export const MERMAID_EXTENSION_KEY = '23392b90-4271-4239-98ca-a3e96c663cbb/63d4d207-ac2f-4273-865c-0240d37f044a/static/mermaid-diagram';
5
+ /** Auto-detect pairs Mermaid code blocks and viewer macros in document order. */
6
+ export function mermaidMacro(document, ordinal) {
7
+ // Random IDs would change the storage hash on every push. Include the ordinal
8
+ // so repeated identical diagrams still have distinct macro instances.
9
+ const hex = createHash('sha256').update(`csync:mermaid:${document}:${ordinal}`).digest('hex');
10
+ const id = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
11
+ const node = `<ac:adf-node type="extension"><ac:adf-attribute key="extension-type">com.atlassian.ecosystem</ac:adf-attribute><ac:adf-attribute key="extension-key">${MERMAID_EXTENSION_KEY}</ac:adf-attribute><ac:adf-attribute key="parameters"><ac:adf-parameter key="local-id">${id}</ac:adf-parameter><ac:adf-parameter key="extension-id">ari:cloud:ecosystem::extension/${MERMAID_EXTENSION_KEY}</ac:adf-parameter><ac:adf-parameter key="extension-title">Mermaid diagram</ac:adf-parameter></ac:adf-attribute><ac:adf-attribute key="text">Mermaid diagram</ac:adf-attribute><ac:adf-attribute key="layout">default</ac:adf-attribute><ac:adf-attribute key="local-id">${id}</ac:adf-attribute></ac:adf-node>`;
12
+ return `<ac:adf-extension>${node}<ac:adf-fallback>${node}</ac:adf-fallback></ac:adf-extension>\n`;
13
+ }
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.5.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",