@wonseok-han/confluence-sync 1.1.0 → 1.2.1

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
@@ -100,8 +100,8 @@ confluence-sync --dry-run # 대상·상태(신규/변경/동일)
100
100
  confluence-sync # 변경된 문서만 갱신 (+ 신규 생성)
101
101
 
102
102
  confluence-sync --base /abs/path/docs # 동기화 루트 지정 (env 미설정 시 필수)
103
- confluence-sync guide/ # 지정 폴더만 (부모 README 자동 포함)
104
- confluence-sync guide/setup.md # 단일 문서만
103
+ confluence-sync guide/ # 지정 폴더만
104
+ confluence-sync guide/setup.md # 단일 문서만 (상위 README 는 발행되지 않음)
105
105
  confluence-sync --force # 변경 감지 무시, 전체 강제 갱신
106
106
  confluence-sync --verify # 변경 없는 문서도 페이지 존재 확인
107
107
  confluence-sync --rebuild # 매핑된 페이지 전부 삭제 후 재생성
@@ -114,6 +114,8 @@ confluence-sync --exclude '*-draft.md' # 제외 패턴 (반복 가능)
114
114
  - README 가 **없는** 폴더는 **Confluence 폴더**로 생성되고 그 아래로 문서가 들어갑니다 (`write:folder` 스코프 필요).
115
115
  - `README.md` 는 폴더 맨 앞, 나머지는 파일명순(숫자 prefix `01-`, `10-` … 로 순서 제어)으로 정렬되어 부모가 자식보다 먼저 생성됩니다.
116
116
 
117
+ > **경로를 지정해 동기화할 때**는 상위 `README.md` 가 **발행 대상이 아닙니다.** 자식을 붙일 부모 pageId 를 얻는 데만 쓰이고, 내용이 바뀌어 있어도 갱신하지 않습니다(`· 상위` 로 표시). 아직 페이지가 없으면 자식을 붙일 곳이 없으므로 그때는 생성합니다. 상위 문서도 갱신하려면 함께 지정하세요.
118
+
117
119
  ```
118
120
  docs/ → [CONFLUENCE_PARENT_ID 또는 공간 최상위]
119
121
  ├── README.md → 루트 페이지
@@ -129,9 +131,36 @@ docs/ → [CONFLUENCE_PARENT_ID 또는 공간 최상위]
129
131
  - 페이지 제목은 **frontmatter `title` → 첫 `# 제목` → 파일명** 순으로 정해지고, 본문은 storage format 으로 변환됩니다.
130
132
  - 코드블록 → **code 매크로**, 표·리스트·헤딩은 그대로.
131
133
  - **내부 `.md` 링크**와 **`[[wikilink]]`** → Confluence 페이지 링크로 자동 변환.
134
+ - **섹션 링크** `[§1 개요](#1-개요)` · `[§6](./04-구현체.md#6-zkp)` · `[[문서#헤딩 텍스트]]` → Confluence 앵커 링크. [아래 참조](#섹션-링크-앵커)
132
135
  - **로컬 이미지**와 **`![[embed]]`** → 페이지 첨부로 업로드 후 참조 (같은 파일명은 새 버전으로 갱신). 외부 URL 이미지는 그대로.
133
136
  - **YAML frontmatter** 는 메타데이터로만 쓰이고 본문에 새지 않습니다.
134
137
 
138
+ ### 섹션 링크 (앵커)
139
+
140
+ 문서는 표준 마크다운 그대로 두면 됩니다. 이 도구가 Confluence 쪽 앵커를 만들어 맞춰 줍니다.
141
+
142
+ **Confluence 가 헤딩에 자동으로 붙이는 id 에는 기대지 않습니다.** 그 규칙은 Cloud 와 Data Center 가 다르고(Cloud 는 공백만 하이픈으로 바꾸고, DC 는 `페이지제목-헤딩` 형태) 예고 없이 바뀌기 때문입니다. 대신 **링크 대상이 되는 헤딩 앞에 [Anchor 매크로](https://confluence.atlassian.com/doc/anchors-139442.html)로 이름(= 마크다운 슬러그)을 직접 심고** 링크가 그 이름을 가리키게 합니다 — 양쪽 다 이 도구가 정한 값이라 어느 배포판에서도 맞습니다. 실제로 가리켜지는 헤딩에만 심으므로 페이지가 지저분해지지 않습니다.
143
+
144
+ | 쓰는 것 | Confluence 에서 |
145
+ | --- | --- |
146
+ | `[§1 개요](#1-개요)` | 같은 페이지의 그 헤딩으로 이동 |
147
+ | `[§6 ZKP](./04-구현체.md#6-zkp)` | 그 페이지의 그 헤딩으로 이동 |
148
+ | `[[04-구현체#6. ZKP]]` | 위와 동일 (Obsidian 표기) |
149
+
150
+ - 슬러그 규칙은 **GitHub 과 동일**합니다 — 소문자화 → 문자·숫자 아닌 것 제거 → 공백 하나당 하이픈 하나. 공백을 접지 않으므로 `제목 · 블록` 의 `--` 도 그대로 맞습니다. 겹치는 헤딩에는 `-1`·`-2` 가 붙습니다.
151
+ - 문서의 첫 `# H1` 을 가리키는 링크는 **페이지 최상단**이 됩니다 (H1 은 Confluence 페이지 제목이 되므로).
152
+ - **대응하는 헤딩을 못 찾으면 링크를 바꾸지 않고 경고합니다.** 엉뚱한 섹션으로 보내는 대신 알려주는 쪽을 택했습니다 — 대개 오타나 깨진 상대경로입니다.
153
+
154
+ ```
155
+ $ confluence-sync --base ./docs --dry-run
156
+ [변경] core-tech/03-standards.md → "표준 참조" (…, 내부링크: 36, 섹션링크: 30, 앵커: 15, 이미지: 0)
157
+ ⚠ 대응 헤딩 없는 앵커 poc/02-zkp/legacy/README.md #부록-a-개념-다리
158
+ ```
159
+
160
+ `섹션링크` 는 이 문서가 **가리키는** 링크 수, `앵커` 는 이 문서에 **심긴** 앵커 수입니다(다른 문서가 가리키는 것 포함).
161
+
162
+ pull 은 반대로 Confluence 앵커를 마크다운 슬러그로 되돌립니다(`--obsidian` 이면 `[[문서#헤딩 텍스트]]`).
163
+
135
164
  ### 변경 감지와 매핑
136
165
 
137
166
  - 문서의 `제목 + 본문 + 참조 이미지 내용` 해시를 저장해두고, **바뀐 문서만** 새 버전으로 올립니다(`= 변경없음` 은 API 호출조차 하지 않음). `--force` 로 무시할 수 있습니다.
@@ -175,6 +204,7 @@ confluence-sync pull --space --obsidian --out ~/MyVault # 내부 링크를 [[w
175
204
  - **`--space`** — 대상 없이 스페이스 전체를 가져옵니다. 페이지가 많으면 시간이 걸립니다.
176
205
  - **이미지/첨부** 는 문서별로 `attachments/<문서명>/` 아래에 내려받고 링크를 그 경로로 씁니다 (문서 폴더가 이미지로 지저분해지지 않음). 다운로드에는 `read:attachment` 스코프가 필요합니다.
177
206
  - **내부 링크** 는 같은 실행에서 함께 받은 페이지끼리 **상대 `.md` 링크로 이어집니다.** 받지 않은 페이지는 절대 URL 로 남습니다(깨진 링크를 만들지 않음).
207
+ - **섹션 링크** 는 Confluence 앵커(`#페이지제목헤딩텍스트`)를 그 문서의 **마크다운 슬러그로 되돌립니다** — 그대로 두면 Obsidian·GitHub 어디서도 걸리지 않기 때문입니다. `--obsidian` 이면 `[[문서#헤딩 텍스트]]` 로 씁니다.
178
208
  - 각 문서 머리에 **frontmatter** 가 붙습니다 — `title` · `pageId` · `spaceKey` · `source` · `updated`. `pageId` 가 다시 push 할 때의 앵커입니다.
179
209
  - 제목은 frontmatter 의 `title` 로만 들어가고 **본문에 `# 제목` 을 따로 넣지 않습니다** — 제목을 별도로 표시하는 뷰어(Obsidian 등)에서 두 번 보이기 때문입니다. push 는 `title` 을 먼저 읽으므로 왕복에 문제가 없습니다.
180
210
  - 본문은 `export_view`(HTML)를 [turndown](https://github.com/mixmark-io/turndown) 으로 변환합니다 — 코드블록(언어 포함, 미지정은 `plaintext`), GFM 표, 리스트 등.
@@ -244,6 +274,7 @@ confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault
244
274
  - 외부 URL·이미지·깨진 링크(대상 파일 없음)는 **그대로 둡니다.**
245
275
  - 파일명이 트리 안에서 겹치면(`README.md` 여러 개 등) `[[wikilink]]` 로 바꾸지 않고 상대 링크를 유지합니다 — 이름만으로는 어느 노트인지 확정할 수 없기 때문입니다.
246
276
  - 코드블록·인라인 코드 안의 링크는 손대지 않습니다.
277
+ - **섹션 링크는 표기가 다릅니다** — 표준 마크다운은 슬러그(`문서.md#6-zkp`), Obsidian 은 헤딩 텍스트(`[[문서#6. ZKP]]`)를 씁니다. 대상 문서의 헤딩을 찾아 옮기고, 못 찾으면 섹션 없이 문서 링크로 둡니다. Obsidian 블록 참조(`#^id`)는 마크다운에 대응물이 없어 같은 방식으로 처리합니다.
247
278
 
248
279
  ### 보정 (`--fix`)
249
280
 
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
- const anchor = hash.length ? `#${hash.join('#')}` : '';
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
- return { title: (data.title || title).trim(), body, fm: data };
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 buildTitleIndex(rels, baseDir) {
50
- const idx = {};
51
- for (const rel of rels)
52
- idx[rel] = readDoc(baseDir, rel).title;
53
- return idx;
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
@@ -40,7 +40,10 @@ ${h('동기화 옵션:')}
40
40
  ${o('-v, --version')} 버전 출력
41
41
 
42
42
  ${h('경로 인자:')}
43
- 파일/폴더를 주면 그 문서만 동기화합니다(부모 README 자동 포함).
43
+ 파일/폴더를 주면 ${bold('그 문서만')} 발행합니다.
44
+ 상위 ${o('README.md')} 는 자식을 붙일 부모 pageId 를 얻는 데만 쓰이고 내용은 갱신되지 않습니다
45
+ ${dim('(아직 페이지가 없으면 자식을 붙일 곳이 없으므로 그때는 생성됩니다).')}
46
+ 상위 문서까지 갱신하려면 함께 지정하세요.
44
47
  예) ${o('confluence-sync 20-design')} ${o('confluence-sync 90-glossary.md')}
45
48
 
46
49
  ${h('pull(역방향) 옵션:')}
@@ -67,12 +70,20 @@ ${h('convert(이미 받아둔 .md 손보기) 옵션:')} ${dim('Confluence 호출
67
70
  ${o('confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault')}
68
71
  ${o('confluence-sync convert --fix ./docs/가이드/설치.md --base ./docs')} ${dim('(파일 하나만)')}
69
72
 
73
+ ${h('섹션 링크(앵커):')}
74
+ ${o('[§1 개요](#1-개요)')} 처럼 헤딩을 가리키는 링크가 Confluence 에서도 동작합니다.
75
+ 같은 문서(${o('#슬러그')})·다른 문서(${o('문서.md#슬러그')})·Obsidian(${o('[[문서#헤딩]]')}) 모두 지원합니다.
76
+ Confluence 의 자동 헤딩 id 는 배포판마다 달라서, 가리켜지는 헤딩 앞에 ${dim('Anchor 매크로')}로
77
+ 이름을 직접 심고 그 이름을 가리킵니다. pull 은 반대로 슬러그를 되돌립니다.
78
+ 대응하는 헤딩을 못 찾으면 링크를 바꾸지 않고 ${dim('⚠ 대응 헤딩 없는 앵커')} 로 알려 줍니다
79
+ ${dim('(대개 문서 쪽 오타나 깨진 상대경로입니다)')}.
80
+
70
81
  ${h('Obsidian:')}
71
82
  pull 결과는 vault 에서 바로 열립니다. 함께 받은 페이지끼리의 내부 링크가 이어져 그래프·백링크가
72
83
  동작하고, 각 문서의 frontmatter 에 pageId 가 남아 원본 페이지를 가리킵니다.
73
84
  [[wikilink]] 표기를 원하면 ${o('pull --obsidian')}, 이미 받아둔 문서라면 ${o('convert --to obsidian')}.
74
85
 
75
- push 는 vault 문법을 그대로 이해합니다: ${o('[[wikilink]]')} · ${o('![[embed]]')} · YAML frontmatter
86
+ push 는 vault 문법을 그대로 이해합니다: ${o('[[wikilink]]')} · ${o('![[embed]]')} · ${o('[[문서#헤딩]]')} · YAML frontmatter
76
87
  (frontmatter 의 ${o('title')} 이 페이지 제목이 되고, ${o('pageId')} 가 있으면 매핑이 없어도 그 페이지를 갱신합니다).
77
88
 
78
89
  ${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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 = { fileDir: '', titleIndex: {}, images: [], internalLinks: 0, linkStack: [], linkedTitles: new Set() };
32
+ let ctx = emptyCtx();
27
33
  let baseDir = '';
28
- /** 링크 href 가 base 내부 .md 면 그 페이지 제목 반환 */
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:|#)/i.test(href))
50
+ if (!href || /^(https?:|mailto:)/i.test(href))
31
51
  return null;
32
- const pathPart = href.split('#')[0];
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
- return ctx.titleIndex[rel] ?? null;
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 targetTitle = resolveInternalLink(href);
48
- if (targetTitle) {
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
- ctx.linkedTitles.add(targetTitle);
52
- return `<ac:link><ri:page ri:content-title="${escapeXml(targetTitle)}" /><ac:link-body>`;
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, resolveLink) {
136
+ export function toStorage(markdown, rel, titleIndex, base, opts = {}) {
92
137
  baseDir = base;
93
- ctx = { fileDir: dirname(rel) === '.' ? '' : dirname(rel), titleIndex, images: [], internalLinks: 0, linkStack: [], linkedTitles: new Set() };
94
- const src = resolveLink ? resolveWikilinks(markdown, resolveLink) : markdown;
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 { storage, images: ctx.images, internalLinks: ctx.internalLinks, linkedTitles: [...ctx.linkedTitles] };
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
- return embed ? `![${label}](${dest(found)})` : `[${label}](${dest(found + (hash ?? ''))})`;
78
+ if (embed)
79
+ return `![${label}](${dest(found)})`;
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
- return label === name ? `[[${name}]]` : `[[${name}|${label}]]`;
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 + anchor)})`;
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
- const relinked = relinkPass(ctx.written, ctx.pathById, ctx.obsidian);
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, buildTitleIndex, buildFolderIndex, buildVault, vaultResolver, readDoc, parentKeyOf, containerKeyOf, sortForSync, resolveSelection, withParents, } from './docs.js';
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,15 +108,32 @@ 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
- const titleIndex = buildTitleIndex(allRel, BASE_DIR); // 링크 변환은 항상 전체 기준
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
- const render = (rel, body) => toStorage(body, rel, titleIndex, BASE_DIR, vaultResolver(rel, vault));
115
- const selected = positionals.length
116
- ? withParents(resolveSelection(allRel, positionals, BASE_DIR), folderIndex)
117
- : allRel;
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
+ });
125
+ // 사용자가 실제로 고른 문서. 경로 인자가 없으면 전체가 대상이다.
126
+ const picked = positionals.length ? resolveSelection(allRel, positionals, BASE_DIR) : allRel;
127
+ const explicit = new Set(picked);
128
+ // 상위 README 를 함께 넣는 것은 **자식의 부모 pageId 를 얻기 위해서**다(발행 대상이 아니다).
129
+ const selected = positionals.length ? withParents(picked, folderIndex) : allRel;
118
130
  const files = sortForSync(selected);
119
- const scope = positionals.length ? `선택 ${files.length}/${allRel.length}건` : `${files.length}건`;
131
+ /** 딸려온 상위 README 인가 — 이미 페이지가 있으면 내용은 건드리지 않는다. */
132
+ const isCarriedParent = (rel) => !explicit.has(rel);
133
+ const scope = positionals.length
134
+ ? `선택 ${explicit.size}/${allRel.length}건` +
135
+ (files.length > explicit.size ? dim(` (+상위 ${files.length - explicit.size})`) : '')
136
+ : `${files.length}건`;
120
137
  const ignoredNote = ignoredCount ? dim(` (제외 ${ignoredCount})`) : '';
121
138
  console.log(`${dim('base:')} ${cyan(BASE_DIR)}\n${dim('대상:')} ${scope}${ignoredNote}`);
122
139
  // ---- 경로 헬퍼 ----
@@ -157,8 +174,14 @@ async function main() {
157
174
  const fex = mapping[`${d}/`];
158
175
  console.log(` [${fex?.pageId ? gray('폴더') : green('폴더+')}] 📁 ${d}/`);
159
176
  }
177
+ const ex0 = mapping[rel];
178
+ // 딸려온 상위 README 는 pageId 만 쓰고 지나간다 — 고르지 않은 문서를 발행하지 않는다
179
+ if (isCarriedParent(rel) && ex0?.pageId) {
180
+ console.log(` [${gray('상위')}] ${rel} ${dim(`(pageId #${ex0.pageId} 만 사용)`)}`);
181
+ continue;
182
+ }
160
183
  const { title, body, fm } = readDoc(BASE_DIR, rel);
161
- const r = render(rel, body);
184
+ const r = render(rel, body, title);
162
185
  const hash = docHash(title, r);
163
186
  const ex = mapping[rel];
164
187
  // 매핑엔 없지만 frontmatter 에 pageId 가 있으면 신규 생성이 아니라 기존 페이지에 연결된다
@@ -166,7 +189,12 @@ async function main() {
166
189
  ? (fm.pageId ? cyan('연결') : green('신규'))
167
190
  : ex.hash === hash ? gray('동일') : yellow('변경');
168
191
  const pk = parentKeyOf(rel, folderIndex);
169
- console.log(` [${status}] ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}, 내부링크: ${r.internalLinks}, 이미지: ${r.images.length})`)}`);
192
+ const anchorNote = r.anchorLinks || r.anchorsPlaced
193
+ ? `, 섹션링크: ${r.anchorLinks}, 앵커: ${r.anchorsPlaced}` : '';
194
+ console.log(` [${status}] ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}, 내부링크: ${r.internalLinks}${anchorNote}, 이미지: ${r.images.length})`)}`);
195
+ if (r.deadAnchors.length) {
196
+ console.error(yellow(` ⚠ 대응 헤딩 없는 앵커 ${r.deadAnchors.length}개: `) + dim(r.deadAnchors.slice(0, 3).join(' ')));
197
+ }
170
198
  }
171
199
  console.log('\n' + dim('--dry-run: 실제 호출 없음.'));
172
200
  return;
@@ -178,7 +206,7 @@ async function main() {
178
206
  saveMapping(MAPPING_PATH, {});
179
207
  }
180
208
  const work = REBUILD ? {} : mapping;
181
- let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0, linked = 0;
209
+ let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0, linked = 0, carried = 0;
182
210
  const rendered = new Map();
183
211
  const recreatedTitles = new Set(); // 이번에 재생성된 문서의 제목
184
212
  const touched = new Set(); // 이번에 발행(생성/갱신/재생성)된 문서
@@ -228,10 +256,21 @@ async function main() {
228
256
  console.error(yellow(` ✗ 건너뜀 ${rel} (상위 폴더 미생성)`));
229
257
  continue;
230
258
  }
259
+ // 딸려온 상위 README 는 자식의 부모 pageId 를 얻으려고 들어온 것이다.
260
+ // 이미 페이지가 있으면 내용은 건드리지 않는다 — `csync <파일>` 이 고르지 않은 문서를 발행하면 안 된다.
261
+ // (페이지가 아직 없으면 자식을 붙일 곳이 없으므로 아래에서 생성한다)
262
+ if (isCarriedParent(rel) && work[rel]?.pageId) {
263
+ console.log(` ${gray('· 상위')} ${rel} ${dim(`(pageId #${work[rel].pageId} 만 사용)`)}`);
264
+ carried++;
265
+ continue;
266
+ }
231
267
  const { title, body, fm } = readDoc(BASE_DIR, rel);
232
- const r = render(rel, body);
268
+ const r = render(rel, body, title);
233
269
  const hash = docHash(title, r);
234
270
  const pk = parentKeyOf(rel, folderIndex);
271
+ if (r.deadAnchors.length) {
272
+ console.error(yellow(` ⚠ 대응 헤딩 없는 앵커 ${rel}`) + dim(` ${r.deadAnchors.slice(0, 3).join(' ')}${r.deadAnchors.length > 3 ? ` 외 ${r.deadAnchors.length - 3}` : ''}`));
273
+ }
235
274
  const parentId = pk ? work[pk]?.pageId : env.parentId;
236
275
  rendered.set(rel, { ...r, title, hash, parentId });
237
276
  if (pk && !parentId) {
@@ -314,6 +353,7 @@ async function main() {
314
353
  ` ${gray(`변경없음 ${skipped}`)}` +
315
354
  (linked ? ` ${cyan(`연결 ${linked}`)}` : '') +
316
355
  (foldersMade ? ` ${cyan(`폴더 ${foldersMade}`)}` : '') +
317
- ` ${dim(`대상 ${files.length}`)}`);
356
+ (carried ? ` ${gray(`상위 ${carried}`)}` : '') +
357
+ ` ${dim(`대상 ${files.length - carried}`)}`);
318
358
  }
319
359
  main().catch((e) => { console.error(e); process.exit(1); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonseok-han/confluence-sync",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "description": "Confluence Cloud ↔ Markdown 양방향 동기화 CLI — 마크다운을 Confluence 페이지로 발행(push)하고, Confluence 페이지·폴더·스페이스를 마크다운으로 가져옵니다(pull).",
6
6
  "keywords": [