@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 +12 -1
- package/dist/attachments.js +59 -0
- package/dist/convert.js +74 -14
- package/dist/docs.js +1 -1
- package/dist/footnotes.js +110 -0
- package/dist/help.js +3 -0
- package/dist/markdown.js +8 -1
- package/dist/obsidian.js +4 -2
- package/dist/pdf-pages.js +148 -0
- package/dist/pdf-renderer.js +63 -0
- package/dist/sync.js +1 -1
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ confluence-sync --version
|
|
|
20
20
|
confluence-sync --help
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
공개 npm 패키지라 **토큰이나 `.npmrc` 설정이 필요 없습니다.** (Node
|
|
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 {
|
|
13
|
-
import {
|
|
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
|
-
|
|
31
|
-
const
|
|
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(
|
|
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
|
|
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 && (
|
|
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
|
|
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 `})`;
|
|
90
|
+
return ` && 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 `<!-- 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`).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
|
+
"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": ">=
|
|
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",
|