@wonseok-han/confluence-sync 1.1.0 → 1.2.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 +29 -0
- package/dist/anchors.js +157 -0
- package/dist/convert.js +21 -6
- package/dist/docs.js +76 -12
- package/dist/help.js +9 -1
- package/dist/markdown.js +76 -14
- package/dist/obsidian.js +7 -1
- package/dist/pull.js +73 -7
- package/dist/sync.js +24 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -129,9 +129,36 @@ docs/ → [CONFLUENCE_PARENT_ID 또는 공간 최상위]
|
|
|
129
129
|
- 페이지 제목은 **frontmatter `title` → 첫 `# 제목` → 파일명** 순으로 정해지고, 본문은 storage format 으로 변환됩니다.
|
|
130
130
|
- 코드블록 → **code 매크로**, 표·리스트·헤딩은 그대로.
|
|
131
131
|
- **내부 `.md` 링크**와 **`[[wikilink]]`** → Confluence 페이지 링크로 자동 변환.
|
|
132
|
+
- **섹션 링크** `[§1 개요](#1-개요)` · `[§6](./04-구현체.md#6-zkp)` · `[[문서#헤딩 텍스트]]` → Confluence 앵커 링크. [아래 참조](#섹션-링크-앵커)
|
|
132
133
|
- **로컬 이미지**와 **`![[embed]]`** → 페이지 첨부로 업로드 후 참조 (같은 파일명은 새 버전으로 갱신). 외부 URL 이미지는 그대로.
|
|
133
134
|
- **YAML frontmatter** 는 메타데이터로만 쓰이고 본문에 새지 않습니다.
|
|
134
135
|
|
|
136
|
+
### 섹션 링크 (앵커)
|
|
137
|
+
|
|
138
|
+
문서는 표준 마크다운 그대로 두면 됩니다. 이 도구가 Confluence 쪽 앵커를 만들어 맞춰 줍니다.
|
|
139
|
+
|
|
140
|
+
**Confluence 가 헤딩에 자동으로 붙이는 id 에는 기대지 않습니다.** 그 규칙은 Cloud 와 Data Center 가 다르고(Cloud 는 공백만 하이픈으로 바꾸고, DC 는 `페이지제목-헤딩` 형태) 예고 없이 바뀌기 때문입니다. 대신 **링크 대상이 되는 헤딩 앞에 [Anchor 매크로](https://confluence.atlassian.com/doc/anchors-139442.html)로 이름(= 마크다운 슬러그)을 직접 심고** 링크가 그 이름을 가리키게 합니다 — 양쪽 다 이 도구가 정한 값이라 어느 배포판에서도 맞습니다. 실제로 가리켜지는 헤딩에만 심으므로 페이지가 지저분해지지 않습니다.
|
|
141
|
+
|
|
142
|
+
| 쓰는 것 | Confluence 에서 |
|
|
143
|
+
| --- | --- |
|
|
144
|
+
| `[§1 개요](#1-개요)` | 같은 페이지의 그 헤딩으로 이동 |
|
|
145
|
+
| `[§6 ZKP](./04-구현체.md#6-zkp)` | 그 페이지의 그 헤딩으로 이동 |
|
|
146
|
+
| `[[04-구현체#6. ZKP]]` | 위와 동일 (Obsidian 표기) |
|
|
147
|
+
|
|
148
|
+
- 슬러그 규칙은 **GitHub 과 동일**합니다 — 소문자화 → 문자·숫자 아닌 것 제거 → 공백 하나당 하이픈 하나. 공백을 접지 않으므로 `제목 · 블록` 의 `--` 도 그대로 맞습니다. 겹치는 헤딩에는 `-1`·`-2` 가 붙습니다.
|
|
149
|
+
- 문서의 첫 `# H1` 을 가리키는 링크는 **페이지 최상단**이 됩니다 (H1 은 Confluence 페이지 제목이 되므로).
|
|
150
|
+
- **대응하는 헤딩을 못 찾으면 링크를 바꾸지 않고 경고합니다.** 엉뚱한 섹션으로 보내는 대신 알려주는 쪽을 택했습니다 — 대개 오타나 깨진 상대경로입니다.
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
$ confluence-sync --base ./docs --dry-run
|
|
154
|
+
[변경] core-tech/03-standards.md → "표준 참조" (…, 내부링크: 36, 섹션링크: 30, 앵커: 15, 이미지: 0)
|
|
155
|
+
⚠ 대응 헤딩 없는 앵커 poc/02-zkp/legacy/README.md #부록-a-개념-다리
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`섹션링크` 는 이 문서가 **가리키는** 링크 수, `앵커` 는 이 문서에 **심긴** 앵커 수입니다(다른 문서가 가리키는 것 포함).
|
|
159
|
+
|
|
160
|
+
pull 은 반대로 Confluence 앵커를 마크다운 슬러그로 되돌립니다(`--obsidian` 이면 `[[문서#헤딩 텍스트]]`).
|
|
161
|
+
|
|
135
162
|
### 변경 감지와 매핑
|
|
136
163
|
|
|
137
164
|
- 문서의 `제목 + 본문 + 참조 이미지 내용` 해시를 저장해두고, **바뀐 문서만** 새 버전으로 올립니다(`= 변경없음` 은 API 호출조차 하지 않음). `--force` 로 무시할 수 있습니다.
|
|
@@ -175,6 +202,7 @@ confluence-sync pull --space --obsidian --out ~/MyVault # 내부 링크를 [[w
|
|
|
175
202
|
- **`--space`** — 대상 없이 스페이스 전체를 가져옵니다. 페이지가 많으면 시간이 걸립니다.
|
|
176
203
|
- **이미지/첨부** 는 문서별로 `attachments/<문서명>/` 아래에 내려받고 링크를 그 경로로 씁니다 (문서 폴더가 이미지로 지저분해지지 않음). 다운로드에는 `read:attachment` 스코프가 필요합니다.
|
|
177
204
|
- **내부 링크** 는 같은 실행에서 함께 받은 페이지끼리 **상대 `.md` 링크로 이어집니다.** 받지 않은 페이지는 절대 URL 로 남습니다(깨진 링크를 만들지 않음).
|
|
205
|
+
- **섹션 링크** 는 Confluence 앵커(`#페이지제목헤딩텍스트`)를 그 문서의 **마크다운 슬러그로 되돌립니다** — 그대로 두면 Obsidian·GitHub 어디서도 걸리지 않기 때문입니다. `--obsidian` 이면 `[[문서#헤딩 텍스트]]` 로 씁니다.
|
|
178
206
|
- 각 문서 머리에 **frontmatter** 가 붙습니다 — `title` · `pageId` · `spaceKey` · `source` · `updated`. `pageId` 가 다시 push 할 때의 앵커입니다.
|
|
179
207
|
- 제목은 frontmatter 의 `title` 로만 들어가고 **본문에 `# 제목` 을 따로 넣지 않습니다** — 제목을 별도로 표시하는 뷰어(Obsidian 등)에서 두 번 보이기 때문입니다. push 는 `title` 을 먼저 읽으므로 왕복에 문제가 없습니다.
|
|
180
208
|
- 본문은 `export_view`(HTML)를 [turndown](https://github.com/mixmark-io/turndown) 으로 변환합니다 — 코드블록(언어 포함, 미지정은 `plaintext`), GFM 표, 리스트 등.
|
|
@@ -244,6 +272,7 @@ confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault
|
|
|
244
272
|
- 외부 URL·이미지·깨진 링크(대상 파일 없음)는 **그대로 둡니다.**
|
|
245
273
|
- 파일명이 트리 안에서 겹치면(`README.md` 여러 개 등) `[[wikilink]]` 로 바꾸지 않고 상대 링크를 유지합니다 — 이름만으로는 어느 노트인지 확정할 수 없기 때문입니다.
|
|
246
274
|
- 코드블록·인라인 코드 안의 링크는 손대지 않습니다.
|
|
275
|
+
- **섹션 링크는 표기가 다릅니다** — 표준 마크다운은 슬러그(`문서.md#6-zkp`), Obsidian 은 헤딩 텍스트(`[[문서#6. ZKP]]`)를 씁니다. 대상 문서의 헤딩을 찾아 옮기고, 못 찾으면 섹션 없이 문서 링크로 둡니다. Obsidian 블록 참조(`#^id`)는 마크다운에 대응물이 없어 같은 방식으로 처리합니다.
|
|
247
276
|
|
|
248
277
|
### 보정 (`--fix`)
|
|
249
278
|
|
package/dist/anchors.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 헤딩 앵커 — 마크다운과 Confluence 의 서로 다른 "섹션 가리키기" 표기를 잇는다.
|
|
3
|
+
*
|
|
4
|
+
* 마크다운(GitHub·Obsidian·VS Code) [§1](#1-신뢰--trust-registry----블록-01) ← 슬러그
|
|
5
|
+
* Obsidian wikilink [[문서#1. 신뢰 — Trust Registry · 블록 01]] ← 헤딩 텍스트
|
|
6
|
+
*
|
|
7
|
+
* **Confluence 가 헤딩에 자동으로 붙이는 id 에는 기대지 않는다.** 그 규칙은 Cloud 와 Data Center 가
|
|
8
|
+
* 서로 다르고(‑ Cloud 는 공백만 하이픈으로 바꾸고, DC 는 `페이지제목-헤딩` 형태다) 예고 없이 바뀐다.
|
|
9
|
+
* 대신 링크 대상이 되는 헤딩 앞에 **Anchor 매크로로 이름을 직접 심고**, 링크는 그 이름을 가리킨다.
|
|
10
|
+
* 이름은 마크다운 슬러그를 그대로 쓰므로 왕복도 정확해진다 — 양쪽 다 우리가 정한 값이다.
|
|
11
|
+
*/
|
|
12
|
+
const FENCE = /^\s*(?:```|~~~)/;
|
|
13
|
+
/** 인라인 마크다운 표기를 벗긴다 — 슬러그는 렌더된 텍스트 기준이다. */
|
|
14
|
+
function plainText(s) {
|
|
15
|
+
return s
|
|
16
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // [label](url) → label
|
|
17
|
+
.replace(/\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]/g, (_m, t, a) => a ?? t)
|
|
18
|
+
.replace(/`+/g, '')
|
|
19
|
+
.replace(/\*\*|~~|\*/g, '') // _ 는 남긴다: 슬러그가 밑줄을 보존하므로(foo_bar → foo_bar)
|
|
20
|
+
.trim();
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* GitHub 호환 헤딩 슬러그: 소문자 → 문자·숫자·공백·하이픈·밑줄 외 제거 → 공백 하나당 하이픈 하나.
|
|
24
|
+
* 공백을 **접지 않는** 것이 핵심이다 — `제목 · 블록` 처럼 구두점이 빠지면 하이픈이 여러 개 남는다.
|
|
25
|
+
*/
|
|
26
|
+
export function slugifyHeading(text) {
|
|
27
|
+
return plainText(text)
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^\p{L}\p{N}\s_-]/gu, '')
|
|
30
|
+
.replace(/\s/g, '-');
|
|
31
|
+
}
|
|
32
|
+
/** 본문의 ATX 헤딩 목록(코드블록 안은 제외). 슬러그가 겹치면 GitHub 처럼 -1·-2 를 붙인다. */
|
|
33
|
+
export function collectHeadings(md) {
|
|
34
|
+
const out = [];
|
|
35
|
+
const used = new Map();
|
|
36
|
+
let inFence = false;
|
|
37
|
+
for (const line of md.split('\n')) {
|
|
38
|
+
if (FENCE.test(line)) {
|
|
39
|
+
inFence = !inFence;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (inFence)
|
|
43
|
+
continue;
|
|
44
|
+
// 닫는 # 는 앞에 공백이 있을 때만 장식이다(`## C#` 의 # 를 지우지 않도록)
|
|
45
|
+
const m = line.match(/^(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/);
|
|
46
|
+
if (!m)
|
|
47
|
+
continue;
|
|
48
|
+
const text = m[2].trim();
|
|
49
|
+
if (!text)
|
|
50
|
+
continue;
|
|
51
|
+
const base = slugifyHeading(text);
|
|
52
|
+
const n = used.get(base) ?? 0;
|
|
53
|
+
used.set(base, n + 1);
|
|
54
|
+
out.push({ text, slug: n ? `${base}-${n}` : base, level: m[1].length });
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** 슬러그 → 헤딩 텍스트. 먼저 나온 헤딩이 이긴다. */
|
|
59
|
+
export function buildAnchorIndex(md) {
|
|
60
|
+
const idx = new Map();
|
|
61
|
+
for (const h of collectHeadings(md))
|
|
62
|
+
if (!idx.has(h.slug))
|
|
63
|
+
idx.set(h.slug, h.text);
|
|
64
|
+
return idx;
|
|
65
|
+
}
|
|
66
|
+
/** 퍼센트 인코딩된 앵커를 되돌린다(실패하면 원문). */
|
|
67
|
+
export const decodeAnchor = (s) => {
|
|
68
|
+
try {
|
|
69
|
+
return decodeURIComponent(s);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return s;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Confluence 앵커 비교용 키 — 공백·구두점을 모두 지운 소문자.
|
|
77
|
+
* Confluence 는 헤딩 앵커 id 를 만들 때 공백을 지우고 구두점 처리도 버전마다 달라서,
|
|
78
|
+
* 남는 글자(문자·숫자)만으로 맞춰야 안정적이다.
|
|
79
|
+
*/
|
|
80
|
+
export const anchorKey = (s) => s.normalize('NFC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
81
|
+
/**
|
|
82
|
+
* Confluence 가 만든 앵커 fragment 를 이 문서의 헤딩에 맞춘다.
|
|
83
|
+
* id 는 보통 `<페이지제목><헤딩텍스트>`(공백 제거) 형태라 제목 접두사를 허용하고,
|
|
84
|
+
* 그래도 안 맞으면 "헤딩으로 끝나는가"로 한 번 더 시도한다(가장 긴 헤딩이 이긴다).
|
|
85
|
+
*/
|
|
86
|
+
export function matchConfluenceAnchor(fragment, headings, pageTitle) {
|
|
87
|
+
const frag = anchorKey(decodeAnchor(fragment));
|
|
88
|
+
if (!frag)
|
|
89
|
+
return null;
|
|
90
|
+
const titleKey = anchorKey(pageTitle);
|
|
91
|
+
let loose = null;
|
|
92
|
+
let looseLen = 0;
|
|
93
|
+
for (const h of headings) {
|
|
94
|
+
const hk = anchorKey(h.text);
|
|
95
|
+
if (!hk)
|
|
96
|
+
continue;
|
|
97
|
+
if (frag === hk || frag === titleKey + hk)
|
|
98
|
+
return h; // 정확히 맞으면 바로
|
|
99
|
+
if (frag.endsWith(hk) && hk.length > looseLen) {
|
|
100
|
+
loose = h;
|
|
101
|
+
looseLen = hk.length;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return loose;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Obsidian wikilink 의 앵커(`[[문서#헤딩 텍스트]]`)를 마크다운 슬러그로.
|
|
108
|
+
* 중첩 헤딩(`#상위#하위`)은 마지막 것이 실제 목적지고, 블록 참조(`#^id`)는 마크다운에 대응물이 없다.
|
|
109
|
+
*/
|
|
110
|
+
export function wikilinkAnchorToSlug(anchor) {
|
|
111
|
+
const last = anchor.split('#').filter(Boolean).pop();
|
|
112
|
+
if (!last || last.startsWith('^'))
|
|
113
|
+
return null;
|
|
114
|
+
return slugifyHeading(last) || null;
|
|
115
|
+
}
|
|
116
|
+
const MD_LINK = /\[([^\]]*)\]\((<[^>]+>|[^()\s]+)\)/g;
|
|
117
|
+
const WIKILINK = /(!?)\[\[([^\]|#]+)(#[^\]|]+)?(?:\|([^\]]+))?\]\]/g;
|
|
118
|
+
/**
|
|
119
|
+
* 본문에서 **앵커가 붙은 링크만** 훑는다 — 어느 문서의 어느 헤딩에 앵커를 심어야 하는지 알아내기 위해서다.
|
|
120
|
+
* 전체 렌더보다 훨씬 싸고, 코드블록·인라인 코드·이미지는 제외한다.
|
|
121
|
+
*/
|
|
122
|
+
export function collectLinkAnchors(md) {
|
|
123
|
+
const out = [];
|
|
124
|
+
let inFence = false;
|
|
125
|
+
for (const line of md.split('\n')) {
|
|
126
|
+
if (FENCE.test(line)) {
|
|
127
|
+
inFence = !inFence;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (inFence)
|
|
131
|
+
continue;
|
|
132
|
+
// 홀수 인덱스 = 인라인 코드 → 링크로 치지 않는다
|
|
133
|
+
const prose = line.split(/(`+[^`]*`+)/).filter((_, i) => i % 2 === 0).join('');
|
|
134
|
+
for (const m of prose.matchAll(MD_LINK)) {
|
|
135
|
+
if (prose[m.index - 1] === '!')
|
|
136
|
+
continue; // 이미지
|
|
137
|
+
const d = decodeAnchor(m[2].replace(/^<(.*)>$/, '$1'));
|
|
138
|
+
const i = d.indexOf('#');
|
|
139
|
+
if (i < 0 || i === d.length - 1)
|
|
140
|
+
continue;
|
|
141
|
+
if (/^(https?:|mailto:)/i.test(d))
|
|
142
|
+
continue;
|
|
143
|
+
out.push({ dest: d.slice(0, i), fragment: d.slice(i + 1), wiki: false });
|
|
144
|
+
}
|
|
145
|
+
for (const m of prose.matchAll(WIKILINK)) {
|
|
146
|
+
if (m[1] === '!' || !m[3])
|
|
147
|
+
continue; // 임베드·앵커 없는 wikilink
|
|
148
|
+
out.push({ dest: m[2].trim(), fragment: m[3].slice(1), wiki: true });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
/** Anchor 매크로 — 링크 대상 헤딩 바로 앞에 심어 이름을 고정한다. */
|
|
154
|
+
export function anchorMacro(name) {
|
|
155
|
+
const safe = name.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
156
|
+
return `<ac:structured-macro ac:name="anchor"><ac:parameter ac:name="">${safe}</ac:parameter></ac:structured-macro>`;
|
|
157
|
+
}
|
package/dist/convert.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
13
13
|
import { resolve, relative, dirname, basename, join } from 'node:path';
|
|
14
14
|
import { collectMarkdown, collectAssets, buildVault, vaultResolver } from './docs.js';
|
|
15
|
-
import { linksToWikilinks, resolveWikilinks } from './obsidian.js';
|
|
15
|
+
import { linksToWikilinks, resolveWikilinks, splitFrontmatter } from './obsidian.js';
|
|
16
|
+
import { buildAnchorIndex } from './anchors.js';
|
|
16
17
|
import { repairMarkdown, totalFixes } from './repair.js';
|
|
17
18
|
import { bold, cyan, dim, gray, green, red, yellow } from './colors.js';
|
|
18
19
|
function optVal(argv, name) {
|
|
@@ -61,10 +62,10 @@ function commonAncestor(paths) {
|
|
|
61
62
|
* wikilink 는 vault 어디서든 "이름"으로 찾으므로, 파일명이 트리 안에서 유일할 때만 바꾼다
|
|
62
63
|
* (겹치면 엉뚱한 노트로 이어질 수 있어 상대 링크를 그대로 둔다).
|
|
63
64
|
*/
|
|
64
|
-
function toObsidian(text, fileAbs, baseDir, nameCount) {
|
|
65
|
+
function toObsidian(text, fileAbs, baseDir, nameCount, headingOf) {
|
|
65
66
|
return linksToWikilinks(text, (dest, label) => {
|
|
66
67
|
if (/^(https?:|mailto:|#)/i.test(dest))
|
|
67
|
-
return null; // 외부
|
|
68
|
+
return null; // 외부 링크·같은 문서 앵커
|
|
68
69
|
const [pathPart, ...hash] = decode(dest).split('#');
|
|
69
70
|
if (!/\.md$/i.test(pathPart))
|
|
70
71
|
return null;
|
|
@@ -76,9 +77,13 @@ function toObsidian(text, fileAbs, baseDir, nameCount) {
|
|
|
76
77
|
const name = basename(targetAbs).replace(/\.md$/i, '');
|
|
77
78
|
if (nameCount.get(name) !== 1)
|
|
78
79
|
return null; // 이름이 겹치면 상대 링크 유지
|
|
79
|
-
|
|
80
|
+
// Obsidian 의 섹션 링크는 슬러그가 아니라 헤딩 텍스트다. 대상 문서에서 헤딩을 못 찾으면
|
|
81
|
+
// 슬러그를 그대로 넣어봤자 안 걸리므로, 링크는 살리되 섹션은 떼고 문서 최상단으로 보낸다.
|
|
82
|
+
const slug = hash.join('#');
|
|
83
|
+
const heading = slug ? headingOf(targetAbs, slug) : undefined;
|
|
84
|
+
const anchor = heading ? `#${heading}` : '';
|
|
80
85
|
const l = label.trim();
|
|
81
|
-
return l && l !== name ? `[[${name}${anchor}|${l}]]` : `[[${name}${anchor}]]`;
|
|
86
|
+
return l && l !== name ? `[[${name}${anchor}|${l}]]` : anchor ? `[[${name}${anchor}|${name}]]` : `[[${name}]]`;
|
|
82
87
|
});
|
|
83
88
|
}
|
|
84
89
|
export async function runConvert(argv) {
|
|
@@ -139,6 +144,16 @@ export async function runConvert(argv) {
|
|
|
139
144
|
const n = basename(f).replace(/\.md$/i, '');
|
|
140
145
|
nameCount.set(n, (nameCount.get(n) ?? 0) + 1);
|
|
141
146
|
}
|
|
147
|
+
// 섹션 링크(#슬러그 ↔ #헤딩 텍스트)를 옮기려면 대상 문서의 헤딩을 알아야 한다. 필요할 때만 읽는다.
|
|
148
|
+
const anchorCache = new Map();
|
|
149
|
+
const headingOf = (targetAbs, slug) => {
|
|
150
|
+
let idx = anchorCache.get(targetAbs);
|
|
151
|
+
if (!idx) {
|
|
152
|
+
idx = buildAnchorIndex(splitFrontmatter(readFileSync(targetAbs, 'utf8')).body);
|
|
153
|
+
anchorCache.set(targetAbs, idx);
|
|
154
|
+
}
|
|
155
|
+
return idx.get(slug.toLowerCase());
|
|
156
|
+
};
|
|
142
157
|
// 실제로 고쳐 쓸 파일(선택). 경로를 안 주면 base 전체.
|
|
143
158
|
const selected = targets.length
|
|
144
159
|
? allMd.filter((f) => targets.some((t) => {
|
|
@@ -193,7 +208,7 @@ export async function runConvert(argv) {
|
|
|
193
208
|
}
|
|
194
209
|
}
|
|
195
210
|
if (to === 'obsidian')
|
|
196
|
-
text = toObsidian(text, abs, baseDir, nameCount);
|
|
211
|
+
text = toObsidian(text, abs, baseDir, nameCount, headingOf);
|
|
197
212
|
else if (to === 'markdown')
|
|
198
213
|
text = resolveWikilinks(text, vaultResolver(rel, vault));
|
|
199
214
|
// 제자리 변환이면 바뀐 것만 쓰지만, --out 이면 대상 전부를 내보낸다(온전한 트리가 나와야 하므로)
|
package/dist/docs.js
CHANGED
|
@@ -6,6 +6,7 @@ import { readdirSync, readFileSync } from 'node:fs';
|
|
|
6
6
|
import { join, resolve, relative, basename, dirname } from 'node:path';
|
|
7
7
|
import { splitTitleAndBody } from './markdown.js';
|
|
8
8
|
import { splitFrontmatter } from './obsidian.js';
|
|
9
|
+
import { buildAnchorIndex, collectHeadings, collectLinkAnchors, wikilinkAnchorToSlug, decodeAnchor, } from './anchors.js';
|
|
9
10
|
/**
|
|
10
11
|
* 숨김 디렉토리는 문서가 아니다. Obsidian vault 의 `.obsidian/`(설정)·`.trash/`(휴지통) 과
|
|
11
12
|
* `.git/`·`node_modules/` 가 여기서 걸러진다.
|
|
@@ -35,22 +36,85 @@ export function collectMarkdown(dir) {
|
|
|
35
36
|
export function collectAssets(dir) {
|
|
36
37
|
return walk(dir, (n) => !isMd(n));
|
|
37
38
|
}
|
|
38
|
-
/**
|
|
39
|
-
* 문서 1건을 읽어 제목·본문·frontmatter 로 분해한다.
|
|
40
|
-
* 제목 우선순위: frontmatter `title` > 첫 H1 > 파일명. H1 은 어느 경우든 본문에서 제거된다
|
|
41
|
-
* (Confluence 는 페이지 제목을 따로 가지므로 본문에 남으면 제목이 두 번 보인다).
|
|
42
|
-
*/
|
|
43
39
|
export function readDoc(baseDir, rel) {
|
|
44
40
|
const { data, body: afterFm } = splitFrontmatter(readFileSync(resolve(baseDir, rel), 'utf8'));
|
|
41
|
+
// 앵커 사전은 H1 을 **포함해** 만든다 — GitHub 이 중복 슬러그에 -1·-2 를 붙이는 순서와 맞추기 위해서다.
|
|
42
|
+
// (H1 자체를 가리키는 링크는 Confluence 에서 페이지 최상단이 된다)
|
|
43
|
+
const all = collectHeadings(afterFm);
|
|
44
|
+
const anchors = buildAnchorIndex(afterFm);
|
|
45
45
|
const { title, body } = splitTitleAndBody(afterFm, basename(rel).replace(/\.md$/i, ''));
|
|
46
|
-
|
|
46
|
+
// splitTitleAndBody 가 본문에서 첫 H1 을 지우므로 슬러그 목록에서도 같은 것을 뺀다
|
|
47
|
+
const dropped = all.findIndex((h) => h.level === 1);
|
|
48
|
+
return {
|
|
49
|
+
title: (data.title || title).trim(),
|
|
50
|
+
body,
|
|
51
|
+
fm: data,
|
|
52
|
+
anchors,
|
|
53
|
+
bodySlugs: all.filter((_, i) => i !== dropped).map((h) => h.slug),
|
|
54
|
+
linkAnchors: collectLinkAnchors(afterFm),
|
|
55
|
+
};
|
|
47
56
|
}
|
|
48
|
-
/** base 상대 경로 →
|
|
49
|
-
export function
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
/** base 상대 경로 → 제목·앵커 인덱스(링크와 섹션 링크를 해석하는 데 둘 다 필요하다) */
|
|
58
|
+
export function buildDocIndex(rels, baseDir) {
|
|
59
|
+
const titles = {};
|
|
60
|
+
const anchors = {};
|
|
61
|
+
const docs = {};
|
|
62
|
+
for (const rel of rels) {
|
|
63
|
+
const doc = readDoc(baseDir, rel);
|
|
64
|
+
titles[rel] = doc.title;
|
|
65
|
+
anchors[rel] = doc.anchors;
|
|
66
|
+
docs[rel] = doc;
|
|
67
|
+
}
|
|
68
|
+
return { titles, anchors, docs };
|
|
69
|
+
}
|
|
70
|
+
/** `a/b/../c.md` 같은 상대 이동을 정리해 base 상대 경로로 만든다. */
|
|
71
|
+
function normalizeRel(dir, dest) {
|
|
72
|
+
const parts = (dir ? `${dir}/${dest}` : dest).split('/');
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const p of parts) {
|
|
75
|
+
if (p === '' || p === '.')
|
|
76
|
+
continue;
|
|
77
|
+
if (p === '..') {
|
|
78
|
+
if (!out.length)
|
|
79
|
+
return null; // base 밖
|
|
80
|
+
out.pop();
|
|
81
|
+
}
|
|
82
|
+
else
|
|
83
|
+
out.push(p);
|
|
84
|
+
}
|
|
85
|
+
return out.join('/');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 어느 문서의 어느 헤딩이 **실제로 링크 대상인지** 모은다.
|
|
89
|
+
* Confluence 가 헤딩에 붙이는 id 에 기대지 않고 Anchor 매크로로 이름을 심을 것이므로,
|
|
90
|
+
* 심을 곳을 알아야 한다. 아무도 가리키지 않는 헤딩에는 매크로를 넣지 않는다(페이지를 깨끗하게 유지).
|
|
91
|
+
*/
|
|
92
|
+
export function resolveAnchorTargets(docs, vault) {
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const [rel, doc] of Object.entries(docs)) {
|
|
95
|
+
const dir = dirname(rel) === '.' ? '' : dirname(rel);
|
|
96
|
+
for (const la of doc.linkAnchors) {
|
|
97
|
+
let target;
|
|
98
|
+
let slug;
|
|
99
|
+
if (la.wiki) {
|
|
100
|
+
target = vault.notes[la.dest.toLowerCase().replace(/\.md$/i, '')] ?? null;
|
|
101
|
+
slug = wikilinkAnchorToSlug(la.fragment);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
target = la.dest === '' ? rel : normalizeRel(dir, decodeAnchor(la.dest));
|
|
105
|
+
if (target && !/\.md$/i.test(target))
|
|
106
|
+
target = null;
|
|
107
|
+
slug = decodeAnchor(la.fragment).toLowerCase();
|
|
108
|
+
}
|
|
109
|
+
if (!target || !slug)
|
|
110
|
+
continue;
|
|
111
|
+
// 실제로 존재하는 헤딩만 — 없는 앵커는 markdown.ts 가 경고로 잡는다
|
|
112
|
+
if (!docs[target]?.anchors.has(slug))
|
|
113
|
+
continue;
|
|
114
|
+
(out[target] ??= new Set()).add(slug);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
54
118
|
}
|
|
55
119
|
/**
|
|
56
120
|
* wikilink 대상 이름 → base 상대 경로 인덱스.
|
package/dist/help.js
CHANGED
|
@@ -67,12 +67,20 @@ ${h('convert(이미 받아둔 .md 손보기) 옵션:')} ${dim('Confluence 호출
|
|
|
67
67
|
${o('confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault')}
|
|
68
68
|
${o('confluence-sync convert --fix ./docs/가이드/설치.md --base ./docs')} ${dim('(파일 하나만)')}
|
|
69
69
|
|
|
70
|
+
${h('섹션 링크(앵커):')}
|
|
71
|
+
${o('[§1 개요](#1-개요)')} 처럼 헤딩을 가리키는 링크가 Confluence 에서도 동작합니다.
|
|
72
|
+
같은 문서(${o('#슬러그')})·다른 문서(${o('문서.md#슬러그')})·Obsidian(${o('[[문서#헤딩]]')}) 모두 지원합니다.
|
|
73
|
+
Confluence 의 자동 헤딩 id 는 배포판마다 달라서, 가리켜지는 헤딩 앞에 ${dim('Anchor 매크로')}로
|
|
74
|
+
이름을 직접 심고 그 이름을 가리킵니다. pull 은 반대로 슬러그를 되돌립니다.
|
|
75
|
+
대응하는 헤딩을 못 찾으면 링크를 바꾸지 않고 ${dim('⚠ 대응 헤딩 없는 앵커')} 로 알려 줍니다
|
|
76
|
+
${dim('(대개 문서 쪽 오타나 깨진 상대경로입니다)')}.
|
|
77
|
+
|
|
70
78
|
${h('Obsidian:')}
|
|
71
79
|
pull 결과는 vault 에서 바로 열립니다. 함께 받은 페이지끼리의 내부 링크가 이어져 그래프·백링크가
|
|
72
80
|
동작하고, 각 문서의 frontmatter 에 pageId 가 남아 원본 페이지를 가리킵니다.
|
|
73
81
|
[[wikilink]] 표기를 원하면 ${o('pull --obsidian')}, 이미 받아둔 문서라면 ${o('convert --to obsidian')}.
|
|
74
82
|
|
|
75
|
-
push 는 vault 문법을 그대로 이해합니다: ${o('[[wikilink]]')} · ${o('![[embed]]')} · YAML frontmatter
|
|
83
|
+
push 는 vault 문법을 그대로 이해합니다: ${o('[[wikilink]]')} · ${o('![[embed]]')} · ${o('[[문서#헤딩]]')} · YAML frontmatter
|
|
76
84
|
(frontmatter 의 ${o('title')} 이 페이지 제목이 되고, ${o('pageId')} 가 있으면 매핑이 없어도 그 페이지를 갱신합니다).
|
|
77
85
|
|
|
78
86
|
${h('설정(.env):')}
|
package/dist/markdown.js
CHANGED
|
@@ -10,6 +10,7 @@ import { resolve, relative, dirname, basename } from 'node:path';
|
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
11
|
import MarkdownIt from 'markdown-it';
|
|
12
12
|
import { resolveWikilinks } from './obsidian.js';
|
|
13
|
+
import { decodeAnchor, anchorMacro } from './anchors.js';
|
|
13
14
|
export const escapeXml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
14
15
|
export const hashOf = (s) => createHash('sha256').update(s).digest('hex');
|
|
15
16
|
/** markdown-it 이 퍼센트 인코딩한 링크/이미지 경로를 실제 경로로 디코드(실패 시 원본). */
|
|
@@ -22,19 +23,57 @@ const decodePath = (s) => {
|
|
|
22
23
|
}
|
|
23
24
|
};
|
|
24
25
|
const md = new MarkdownIt({ html: true, linkify: true, breaks: false });
|
|
26
|
+
const emptyCtx = () => ({
|
|
27
|
+
fileDir: '', titleIndex: {}, anchorIndex: {}, selfRel: '', selfTitle: '',
|
|
28
|
+
anchorNames: new Set(), bodySlugs: [], headingSeq: 0, anchorsPlaced: 0,
|
|
29
|
+
images: [], internalLinks: 0, anchorLinks: 0, deadAnchors: [], linkStack: [], linkedTitles: new Set(),
|
|
30
|
+
});
|
|
25
31
|
// 렌더 규칙은 md 인스턴스에 한 번만 등록되므로, 현재 변환 컨텍스트를 모듈 변수로 공유한다.
|
|
26
|
-
let ctx =
|
|
32
|
+
let ctx = emptyCtx();
|
|
27
33
|
let baseDir = '';
|
|
28
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* 링크가 가리킬 앵커 이름을 정한다 — **슬러그를 그대로 쓴다.**
|
|
36
|
+
* 대상 헤딩 앞에 같은 이름의 Anchor 매크로가 심기므로 Confluence 의 자동 id 규칙과 무관하게 맞는다.
|
|
37
|
+
* 그 문서의 H1 은 Confluence 페이지 제목이 되므로 앵커가 아니라 '최상단'이다.
|
|
38
|
+
*/
|
|
39
|
+
function anchorOf(rel, fragment, pageTitle) {
|
|
40
|
+
const slug = decodePath(decodeAnchor(fragment)).toLowerCase();
|
|
41
|
+
const heading = ctx.anchorIndex[rel]?.get(slug);
|
|
42
|
+
if (!heading) {
|
|
43
|
+
ctx.deadAnchors.push(`#${slug}`); // 경고에 쓰이므로 사람이 읽을 수 있는 형태로 남긴다
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return heading === pageTitle ? null : slug;
|
|
47
|
+
}
|
|
48
|
+
/** 링크 href 를 Confluence 내부 링크(페이지·앵커)로 해석. 아니면 null. */
|
|
29
49
|
function resolveInternalLink(href) {
|
|
30
|
-
if (!href || /^(https?:|mailto
|
|
50
|
+
if (!href || /^(https?:|mailto:)/i.test(href))
|
|
31
51
|
return null;
|
|
32
|
-
|
|
52
|
+
// 같은 문서 안의 섹션: #슬러그
|
|
53
|
+
if (href.startsWith('#')) {
|
|
54
|
+
const anchor = anchorOf(ctx.selfRel, href.slice(1), ctx.selfTitle);
|
|
55
|
+
return anchor ? { title: null, anchor } : null;
|
|
56
|
+
}
|
|
57
|
+
const [pathPart, ...rest] = href.split('#');
|
|
33
58
|
if (!pathPart || !/\.md$/i.test(pathPart))
|
|
34
59
|
return null;
|
|
35
60
|
const rel = relative(baseDir, resolve(baseDir, ctx.fileDir, decodePath(pathPart)));
|
|
36
|
-
|
|
61
|
+
const title = ctx.titleIndex[rel];
|
|
62
|
+
if (!title)
|
|
63
|
+
return null;
|
|
64
|
+
const fragment = rest.join('#');
|
|
65
|
+
return { title, anchor: fragment ? anchorOf(rel, fragment, title) : null };
|
|
37
66
|
}
|
|
67
|
+
// 링크 대상이 되는 헤딩 바로 앞에 Anchor 매크로를 심어 이름(슬러그)을 고정한다.
|
|
68
|
+
// Confluence 가 헤딩에 붙이는 자동 id 는 Cloud/DC 가 다르고 바뀌므로 거기에 기대지 않는다.
|
|
69
|
+
md.renderer.rules.heading_open = (tokens, idx, opts, _env, self) => {
|
|
70
|
+
const slug = ctx.bodySlugs[ctx.headingSeq++];
|
|
71
|
+
const open = self.renderToken(tokens, idx, opts);
|
|
72
|
+
if (!slug || !ctx.anchorNames.has(slug))
|
|
73
|
+
return open;
|
|
74
|
+
ctx.anchorsPlaced++;
|
|
75
|
+
return anchorMacro(slug) + open;
|
|
76
|
+
};
|
|
38
77
|
md.renderer.rules.fence = (tokens, idx) => {
|
|
39
78
|
const t = tokens[idx];
|
|
40
79
|
const lang = (t.info || '').trim().split(/\s+/)[0];
|
|
@@ -44,12 +83,19 @@ md.renderer.rules.fence = (tokens, idx) => {
|
|
|
44
83
|
};
|
|
45
84
|
md.renderer.rules.link_open = (tokens, idx, opts, _env, self) => {
|
|
46
85
|
const href = tokens[idx].attrGet('href') || '';
|
|
47
|
-
const
|
|
48
|
-
|
|
86
|
+
const t = resolveInternalLink(href);
|
|
87
|
+
// 페이지도 앵커도 못 정하면 Confluence 링크로 만들 게 없다 → 원문 <a> 유지
|
|
88
|
+
if (t && (t.title || t.anchor)) {
|
|
49
89
|
ctx.linkStack.push(true);
|
|
50
90
|
ctx.internalLinks++;
|
|
51
|
-
|
|
52
|
-
|
|
91
|
+
if (t.title)
|
|
92
|
+
ctx.linkedTitles.add(t.title);
|
|
93
|
+
if (t.anchor)
|
|
94
|
+
ctx.anchorLinks++;
|
|
95
|
+
// 앵커 이름은 헤딩 텍스트 그대로다. ri:page 가 없으면 같은 페이지 안의 이동이 된다.
|
|
96
|
+
const anchor = t.anchor ? ` ac:anchor="${escapeXml(t.anchor)}"` : '';
|
|
97
|
+
const page = t.title ? `<ri:page ri:content-title="${escapeXml(t.title)}" />` : '';
|
|
98
|
+
return `<ac:link${anchor}>${page}<ac:link-body>`;
|
|
53
99
|
}
|
|
54
100
|
ctx.linkStack.push(false);
|
|
55
101
|
return self.renderToken(tokens, idx, opts);
|
|
@@ -86,14 +132,30 @@ export function splitTitleAndBody(markdown, fallback) {
|
|
|
86
132
|
}
|
|
87
133
|
/**
|
|
88
134
|
* 본문 markdown 을 storage format 으로 변환(base 기준 상대경로로 링크·이미지 해석).
|
|
89
|
-
* resolveLink 를 주면 Obsidian 의 [[wikilink]]·![[embed]] 를 먼저 표준 링크로 정규화한다.
|
|
90
135
|
*/
|
|
91
|
-
export function toStorage(markdown, rel, titleIndex, base,
|
|
136
|
+
export function toStorage(markdown, rel, titleIndex, base, opts = {}) {
|
|
92
137
|
baseDir = base;
|
|
93
|
-
ctx = {
|
|
94
|
-
|
|
138
|
+
ctx = {
|
|
139
|
+
...emptyCtx(),
|
|
140
|
+
fileDir: dirname(rel) === '.' ? '' : dirname(rel),
|
|
141
|
+
titleIndex,
|
|
142
|
+
anchorIndex: opts.anchorIndex ?? {},
|
|
143
|
+
selfRel: rel,
|
|
144
|
+
selfTitle: opts.title ?? '',
|
|
145
|
+
anchorNames: opts.anchorNames ?? new Set(),
|
|
146
|
+
bodySlugs: opts.bodySlugs ?? [],
|
|
147
|
+
};
|
|
148
|
+
const src = opts.resolveLink ? resolveWikilinks(markdown, opts.resolveLink) : markdown;
|
|
95
149
|
const storage = md.render(src);
|
|
96
|
-
return {
|
|
150
|
+
return {
|
|
151
|
+
storage,
|
|
152
|
+
images: ctx.images,
|
|
153
|
+
internalLinks: ctx.internalLinks,
|
|
154
|
+
anchorLinks: ctx.anchorLinks,
|
|
155
|
+
anchorsPlaced: ctx.anchorsPlaced,
|
|
156
|
+
deadAnchors: ctx.deadAnchors,
|
|
157
|
+
linkedTitles: [...ctx.linkedTitles],
|
|
158
|
+
};
|
|
97
159
|
}
|
|
98
160
|
/**
|
|
99
161
|
* 변경 감지용 문서 해시: 제목 + storage + 참조 로컬 이미지의 내용 해시.
|
package/dist/obsidian.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* frontmatter 는 평면 `key: value` 만 다룬다. 중첩·리스트가 필요할 만큼 복잡한 메타데이터는
|
|
10
10
|
* 이 도구의 관심사가 아니며, 알 수 없는 키는 건드리지 않고 보존한다.
|
|
11
11
|
*/
|
|
12
|
+
import { wikilinkAnchorToSlug } from './anchors.js';
|
|
12
13
|
const FENCE = /^\s*(?:```|~~~)/;
|
|
13
14
|
// [[대상]] · [[대상|별칭]] · [[대상#헤딩]] · ![[임베드]]
|
|
14
15
|
const WIKILINK = /(!?)\[\[([^\]|#]+)(#[^\]|]+)?(?:\|([^\]]+))?\]\]/g;
|
|
@@ -74,7 +75,12 @@ export function resolveWikilinks(md, resolve) {
|
|
|
74
75
|
if (!found)
|
|
75
76
|
return whole; // 해석 실패 → 원문 보존
|
|
76
77
|
const label = (alias ?? target).trim();
|
|
77
|
-
|
|
78
|
+
if (embed)
|
|
79
|
+
return `})`;
|
|
80
|
+
// Obsidian 은 섹션을 헤딩 텍스트로 가리키지만(`#1. 신뢰 — Trust Registry`)
|
|
81
|
+
// 표준 마크다운은 슬러그를 쓴다. 블록 참조(`#^id`)는 대응물이 없어 링크만 남긴다.
|
|
82
|
+
const slug = hash ? wikilinkAnchorToSlug(hash.slice(1)) : null;
|
|
83
|
+
return `[${label}](${dest(found + (slug ? `#${slug}` : ''))})`;
|
|
78
84
|
}));
|
|
79
85
|
}
|
|
80
86
|
// [text](dest) — dest 는 <...> 로 감싸였을 수 있다. 앞의 ! 는 이미지 임베드.
|
package/dist/pull.js
CHANGED
|
@@ -12,12 +12,17 @@ import { resolve, join, dirname, basename, relative } from 'node:path';
|
|
|
12
12
|
import { readEnv, requireEnv } from './config.js';
|
|
13
13
|
import { createClient } from './confluence.js';
|
|
14
14
|
import { htmlToMarkdown, codeLanguagesFromStorage } from './html2md.js';
|
|
15
|
-
import { buildFrontmatter } from './obsidian.js';
|
|
15
|
+
import { buildFrontmatter, splitFrontmatter } from './obsidian.js';
|
|
16
|
+
import { collectHeadings, matchConfluenceAnchor } from './anchors.js';
|
|
16
17
|
import { cyan, dim, green, red, yellow } from './colors.js';
|
|
17
18
|
// 첨부 이미지는 항상 <md위치>/attachments/<문서명>/ 하위에 저장한다.
|
|
18
19
|
const ASSETS_DIR = 'attachments';
|
|
19
20
|
/** 마크다운 링크 중 Confluence 페이지를 가리키는 것: [text](https://.../pages/<id>/...) */
|
|
20
21
|
const PAGE_LINK = /\[([^\]]*)\]\(<?(https?:\/\/[^\s)<>]*?\/pages\/(\d+)[^\s)<>]*?)>?\)/g;
|
|
22
|
+
/** 같은 문서 안의 앵커: [text](#앵커) */
|
|
23
|
+
const SELF_ANCHOR = /\[([^\]]*)\]\(#([^)\s]+)\)/g;
|
|
24
|
+
/** 다른 페이지의 섹션: [text](https://.../pages/<id>/제목#앵커) — 앵커만 따로 잡는다 */
|
|
25
|
+
const PAGE_ANCHOR = /(\[[^\]]*\]\(<?https?:\/\/[^\s)<>]*?\/pages\/(\d+)[^\s)<>#]*)#([^\s)<>]+?)(>?\))/g;
|
|
21
26
|
function optVal(argv, name) {
|
|
22
27
|
const i = argv.indexOf(name);
|
|
23
28
|
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
|
@@ -128,12 +133,66 @@ async function pullNode(ctx, id, destDir) {
|
|
|
128
133
|
return { pages: 0, folders: 0 };
|
|
129
134
|
}
|
|
130
135
|
}
|
|
136
|
+
function readDocInfo(file) {
|
|
137
|
+
const { data, body } = splitFrontmatter(readFileSync(file, 'utf8'));
|
|
138
|
+
const headings = collectHeadings(body);
|
|
139
|
+
return {
|
|
140
|
+
title: data.title ?? '',
|
|
141
|
+
headings,
|
|
142
|
+
textBySlug: new Map(headings.map((h) => [h.slug, h.text])),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Confluence 가 만든 헤딩 앵커를 마크다운 슬러그로 바꾼다.
|
|
147
|
+
*
|
|
148
|
+
* Confluence 의 앵커 id 는 `<페이지제목><헤딩텍스트>`(공백 제거) 라서 Obsidian·GitHub 에서는
|
|
149
|
+
* 아무 데도 가리키지 못한다. 실제 헤딩을 찾아 그 문서의 슬러그로 바꿔 줘야 링크가 산다.
|
|
150
|
+
* relinkPass 보다 **먼저** 돈다 — 아직 링크가 절대 URL 이라 어느 페이지인지 id 로 알 수 있기 때문이다.
|
|
151
|
+
*/
|
|
152
|
+
export function anchorPass(written, pathById) {
|
|
153
|
+
const info = new Map();
|
|
154
|
+
for (const f of written)
|
|
155
|
+
info.set(f, readDocInfo(f));
|
|
156
|
+
let count = 0;
|
|
157
|
+
for (const file of written) {
|
|
158
|
+
const self = info.get(file);
|
|
159
|
+
const before = readFileSync(file, 'utf8');
|
|
160
|
+
// 우리가 올린 페이지는 Anchor 매크로 이름이 이미 마크다운 슬러그라 그대로 두면 된다.
|
|
161
|
+
// Confluence 에서 직접 쓴 페이지만 헤딩을 찾아 맞춘다.
|
|
162
|
+
const toSlug = (frag, d) => {
|
|
163
|
+
if (d.textBySlug.has(frag))
|
|
164
|
+
return null; // 이미 올바른 슬러그
|
|
165
|
+
return matchConfluenceAnchor(frag, d.headings, d.title)?.slug ?? null;
|
|
166
|
+
};
|
|
167
|
+
let after = before.replace(SELF_ANCHOR, (whole, label, frag) => {
|
|
168
|
+
const slug = toSlug(frag, self);
|
|
169
|
+
if (!slug)
|
|
170
|
+
return whole; // 이미 맞거나 못 찾음 → 손대지 않는다(엉뚱한 곳으로 보내지 않기 위해)
|
|
171
|
+
count++;
|
|
172
|
+
return `[${label}](#${slug})`;
|
|
173
|
+
});
|
|
174
|
+
after = after.replace(PAGE_ANCHOR, (whole, head, id, frag, tail) => {
|
|
175
|
+
const target = pathById.get(id);
|
|
176
|
+
const ti = target && info.get(target);
|
|
177
|
+
if (!ti)
|
|
178
|
+
return whole; // 이번에 안 받은 페이지 → 원본 URL 이 살아 있어야 한다
|
|
179
|
+
const slug = toSlug(frag, ti);
|
|
180
|
+
if (!slug)
|
|
181
|
+
return whole;
|
|
182
|
+
count++;
|
|
183
|
+
return `${head}#${slug}${tail}`;
|
|
184
|
+
});
|
|
185
|
+
if (after !== before)
|
|
186
|
+
writeFileSync(file, after);
|
|
187
|
+
}
|
|
188
|
+
return { count, info };
|
|
189
|
+
}
|
|
131
190
|
/**
|
|
132
191
|
* 함께 받은 페이지를 가리키는 절대 Confluence URL 을 상대 .md 링크(또는 [[wikilink]])로 바꾼다.
|
|
133
192
|
* 이번에 받지 않은 페이지의 링크는 절대 URL 그대로 둔다(깨진 링크를 만들지 않는다).
|
|
134
193
|
* 반환값은 재작성한 링크 수.
|
|
135
194
|
*/
|
|
136
|
-
export function relinkPass(written, pathById, wikilinks) {
|
|
195
|
+
export function relinkPass(written, pathById, wikilinks, info) {
|
|
137
196
|
const noExt = (p) => basename(p).replace(/\.md$/i, '');
|
|
138
197
|
// wikilink 는 vault 어디서든 "이름"으로 찾으므로, 파일명이 유일할 때만 안전하게 쓸 수 있다.
|
|
139
198
|
const nameCount = new Map();
|
|
@@ -149,12 +208,16 @@ export function relinkPass(written, pathById, wikilinks) {
|
|
|
149
208
|
count++;
|
|
150
209
|
const name = noExt(target);
|
|
151
210
|
const label = (text || name).trim();
|
|
211
|
+
const slug = url.includes('#') ? url.split('#').slice(1).join('#') : '';
|
|
152
212
|
if (wikilinks && nameCount.get(name) === 1) {
|
|
153
|
-
|
|
213
|
+
// Obsidian 은 슬러그가 아니라 헤딩 텍스트로 섹션을 가리킨다: [[문서#헤딩 텍스트]]
|
|
214
|
+
const heading = slug ? info?.get(target)?.textBySlug.get(slug) : undefined;
|
|
215
|
+
if (!heading)
|
|
216
|
+
return label === name ? `[[${name}]]` : `[[${name}|${label}]]`;
|
|
217
|
+
return `[[${name}#${heading}|${label}]]`;
|
|
154
218
|
}
|
|
155
|
-
const anchor = url.includes('#') ? '#' + url.split('#').slice(1).join('#') : '';
|
|
156
219
|
const rel = relative(dirname(file), target).split('\\').join('/');
|
|
157
|
-
return `[${label}](${dest(rel +
|
|
220
|
+
return `[${label}](${dest(rel + (slug ? `#${slug}` : ''))})`;
|
|
158
221
|
});
|
|
159
222
|
if (after !== before)
|
|
160
223
|
writeFileSync(file, after);
|
|
@@ -177,9 +240,12 @@ export async function runPull(argv) {
|
|
|
177
240
|
pathById: new Map(),
|
|
178
241
|
};
|
|
179
242
|
const done = (c) => {
|
|
180
|
-
|
|
243
|
+
// 앵커를 먼저 슬러그로 바꾼 뒤 링크를 상대경로로 옮긴다(순서가 바뀌면 어느 페이지의 헤딩인지 알 수 없다).
|
|
244
|
+
const { count: anchors, info } = anchorPass(ctx.written, ctx.pathById);
|
|
245
|
+
const relinked = relinkPass(ctx.written, ctx.pathById, ctx.obsidian, info);
|
|
181
246
|
console.log(`\n${green('완료')} 페이지 ${c.pages}개${c.folders ? `, 폴더 ${c.folders}개` : ''} 생성` +
|
|
182
|
-
(relinked ? ` ${cyan(`내부링크 ${relinked}개 연결`)}` : '')
|
|
247
|
+
(relinked ? ` ${cyan(`내부링크 ${relinked}개 연결`)}` : '') +
|
|
248
|
+
(anchors ? ` ${cyan(`섹션링크 ${anchors}개 복원`)}` : ''));
|
|
183
249
|
};
|
|
184
250
|
// --space: 스페이스 홈페이지(콘텐츠 트리 루트)부터 전체를 재귀적으로 가져옴
|
|
185
251
|
if (wholeSpace) {
|
package/dist/sync.js
CHANGED
|
@@ -18,7 +18,7 @@ import { existsSync, statSync } from 'node:fs';
|
|
|
18
18
|
import { resolve, relative } from 'node:path';
|
|
19
19
|
import { readEnv, requireEnv } from './config.js';
|
|
20
20
|
import { toStorage, docHash } from './markdown.js';
|
|
21
|
-
import { collectMarkdown, collectAssets,
|
|
21
|
+
import { collectMarkdown, collectAssets, buildDocIndex, resolveAnchorTargets, buildFolderIndex, buildVault, vaultResolver, readDoc, parentKeyOf, containerKeyOf, sortForSync, resolveSelection, withParents, } from './docs.js';
|
|
22
22
|
import { loadMapping, saveMapping } from './mapping.js';
|
|
23
23
|
import { createClient } from './confluence.js';
|
|
24
24
|
import { runInit } from './init.js';
|
|
@@ -108,10 +108,20 @@ async function main() {
|
|
|
108
108
|
const allRel = collected.filter((r) => !ignorer.ignores(r)); // 제외된 문서는 모든 단계에서 빠짐
|
|
109
109
|
const ignoredCount = collected.length - allRel.length;
|
|
110
110
|
const folderIndex = buildFolderIndex(allRel);
|
|
111
|
-
|
|
111
|
+
// 링크·섹션 링크 변환은 항상 전체 기준(선택 동기화여도 대상 문서의 제목·헤딩을 알아야 한다)
|
|
112
|
+
const { titles: titleIndex, anchors: anchorIndex, docs } = buildDocIndex(allRel, BASE_DIR);
|
|
112
113
|
// Obsidian [[wikilink]] 해석용 이름 인덱스. 제외된 문서는 링크 대상에서도 빠진다.
|
|
113
114
|
const vault = buildVault(allRel, collectAssets(BASE_DIR).map((f) => relative(BASE_DIR, f)));
|
|
114
|
-
|
|
115
|
+
// 누가 어느 헤딩을 가리키는지 먼저 모은다 — 그 헤딩에만 Anchor 매크로를 심는다.
|
|
116
|
+
// 선택 동기화여도 트리 전체를 봐야 한다(다른 문서가 이 문서의 섹션을 가리킬 수 있으므로).
|
|
117
|
+
const anchorTargets = resolveAnchorTargets(docs, vault);
|
|
118
|
+
const render = (rel, body, title) => toStorage(body, rel, titleIndex, BASE_DIR, {
|
|
119
|
+
resolveLink: vaultResolver(rel, vault),
|
|
120
|
+
anchorIndex,
|
|
121
|
+
title,
|
|
122
|
+
anchorNames: anchorTargets[rel],
|
|
123
|
+
bodySlugs: docs[rel]?.bodySlugs,
|
|
124
|
+
});
|
|
115
125
|
const selected = positionals.length
|
|
116
126
|
? withParents(resolveSelection(allRel, positionals, BASE_DIR), folderIndex)
|
|
117
127
|
: allRel;
|
|
@@ -158,7 +168,7 @@ async function main() {
|
|
|
158
168
|
console.log(` [${fex?.pageId ? gray('폴더') : green('폴더+')}] 📁 ${d}/`);
|
|
159
169
|
}
|
|
160
170
|
const { title, body, fm } = readDoc(BASE_DIR, rel);
|
|
161
|
-
const r = render(rel, body);
|
|
171
|
+
const r = render(rel, body, title);
|
|
162
172
|
const hash = docHash(title, r);
|
|
163
173
|
const ex = mapping[rel];
|
|
164
174
|
// 매핑엔 없지만 frontmatter 에 pageId 가 있으면 신규 생성이 아니라 기존 페이지에 연결된다
|
|
@@ -166,7 +176,12 @@ async function main() {
|
|
|
166
176
|
? (fm.pageId ? cyan('연결') : green('신규'))
|
|
167
177
|
: ex.hash === hash ? gray('동일') : yellow('변경');
|
|
168
178
|
const pk = parentKeyOf(rel, folderIndex);
|
|
169
|
-
|
|
179
|
+
const anchorNote = r.anchorLinks || r.anchorsPlaced
|
|
180
|
+
? `, 섹션링크: ${r.anchorLinks}, 앵커: ${r.anchorsPlaced}` : '';
|
|
181
|
+
console.log(` [${status}] ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}, 내부링크: ${r.internalLinks}${anchorNote}, 이미지: ${r.images.length})`)}`);
|
|
182
|
+
if (r.deadAnchors.length) {
|
|
183
|
+
console.error(yellow(` ⚠ 대응 헤딩 없는 앵커 ${r.deadAnchors.length}개: `) + dim(r.deadAnchors.slice(0, 3).join(' ')));
|
|
184
|
+
}
|
|
170
185
|
}
|
|
171
186
|
console.log('\n' + dim('--dry-run: 실제 호출 없음.'));
|
|
172
187
|
return;
|
|
@@ -229,9 +244,12 @@ async function main() {
|
|
|
229
244
|
continue;
|
|
230
245
|
}
|
|
231
246
|
const { title, body, fm } = readDoc(BASE_DIR, rel);
|
|
232
|
-
const r = render(rel, body);
|
|
247
|
+
const r = render(rel, body, title);
|
|
233
248
|
const hash = docHash(title, r);
|
|
234
249
|
const pk = parentKeyOf(rel, folderIndex);
|
|
250
|
+
if (r.deadAnchors.length) {
|
|
251
|
+
console.error(yellow(` ⚠ 대응 헤딩 없는 앵커 ${rel}`) + dim(` ${r.deadAnchors.slice(0, 3).join(' ')}${r.deadAnchors.length > 3 ? ` 외 ${r.deadAnchors.length - 3}` : ''}`));
|
|
252
|
+
}
|
|
235
253
|
const parentId = pk ? work[pk]?.pageId : env.parentId;
|
|
236
254
|
rendered.set(rel, { ...r, title, hash, parentId });
|
|
237
255
|
if (pk && !parentId) {
|
package/package.json
CHANGED