@wonseok-han/confluence-sync 1.0.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 +138 -6
- package/dist/anchors.js +157 -0
- package/dist/confluence.js +7 -2
- package/dist/convert.js +257 -0
- package/dist/docs.js +137 -12
- package/dist/help.js +38 -4
- package/dist/html2md.js +3 -1
- package/dist/markdown.js +81 -14
- package/dist/obsidian.js +100 -0
- package/dist/pull.js +149 -15
- package/dist/repair.js +145 -0
- package/dist/sync.js +61 -12
- package/package.json +3 -2
package/dist/docs.js
CHANGED
|
@@ -3,28 +3,153 @@
|
|
|
3
3
|
* 폴더의 README.md 가 대표 페이지가 되고 같은 폴더의 다른 문서는 그 자식이 된다.
|
|
4
4
|
*/
|
|
5
5
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
6
|
-
import { join, resolve, relative } from 'node:path';
|
|
6
|
+
import { join, resolve, relative, basename, dirname } from 'node:path';
|
|
7
7
|
import { splitTitleAndBody } from './markdown.js';
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
import { splitFrontmatter } from './obsidian.js';
|
|
9
|
+
import { buildAnchorIndex, collectHeadings, collectLinkAnchors, wikilinkAnchorToSlug, decodeAnchor, } from './anchors.js';
|
|
10
|
+
/**
|
|
11
|
+
* 숨김 디렉토리는 문서가 아니다. Obsidian vault 의 `.obsidian/`(설정)·`.trash/`(휴지통) 과
|
|
12
|
+
* `.git/`·`node_modules/` 가 여기서 걸러진다.
|
|
13
|
+
*/
|
|
14
|
+
const skipDir = (name) => name.startsWith('.') || name === 'node_modules';
|
|
15
|
+
/** dir 이하의 파일 절대경로를 재귀 수집(pick 이 true 인 것만). */
|
|
16
|
+
function walk(dir, pick) {
|
|
10
17
|
const out = [];
|
|
11
18
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
12
19
|
const full = join(dir, entry.name);
|
|
13
|
-
if (entry.isDirectory())
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
if (entry.isDirectory()) {
|
|
21
|
+
if (!skipDir(entry.name))
|
|
22
|
+
out.push(...walk(full, pick));
|
|
23
|
+
}
|
|
24
|
+
else if (entry.isFile() && pick(entry.name)) {
|
|
16
25
|
out.push(full);
|
|
26
|
+
}
|
|
17
27
|
}
|
|
18
28
|
return out;
|
|
19
29
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
const isMd = (name) => name.toLowerCase().endsWith('.md');
|
|
31
|
+
/** dir 이하의 모든 .md 절대경로를 재귀 수집 */
|
|
32
|
+
export function collectMarkdown(dir) {
|
|
33
|
+
return walk(dir, isMd);
|
|
34
|
+
}
|
|
35
|
+
/** dir 이하의 .md 가 아닌 파일(이미지 등 첨부 후보) 절대경로를 재귀 수집 */
|
|
36
|
+
export function collectAssets(dir) {
|
|
37
|
+
return walk(dir, (n) => !isMd(n));
|
|
38
|
+
}
|
|
39
|
+
export function readDoc(baseDir, rel) {
|
|
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
|
+
const { title, body } = splitTitleAndBody(afterFm, basename(rel).replace(/\.md$/i, ''));
|
|
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
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** base 상대 경로 → 제목·앵커 인덱스(링크와 섹션 링크를 해석하는 데 둘 다 필요하다) */
|
|
58
|
+
export function buildDocIndex(rels, baseDir) {
|
|
59
|
+
const titles = {};
|
|
60
|
+
const anchors = {};
|
|
61
|
+
const docs = {};
|
|
23
62
|
for (const rel of rels) {
|
|
24
|
-
const
|
|
25
|
-
|
|
63
|
+
const doc = readDoc(baseDir, rel);
|
|
64
|
+
titles[rel] = doc.title;
|
|
65
|
+
anchors[rel] = doc.anchors;
|
|
66
|
+
docs[rel] = doc;
|
|
26
67
|
}
|
|
27
|
-
return
|
|
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;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* wikilink 대상 이름 → base 상대 경로 인덱스.
|
|
121
|
+
* Obsidian 은 `[[파일명]]`(짧은 이름)과 `[[폴더/파일명]]`(경로) 을 모두 허용하므로 둘 다 등록한다.
|
|
122
|
+
* 짧은 이름이 겹치면 경로가 사전순으로 앞서는 쪽이 이긴다(Obsidian 의 "가장 가까운 것" 규칙과는 다르지만 결정적이다).
|
|
123
|
+
*/
|
|
124
|
+
export function buildVault(mdRels, assetRels) {
|
|
125
|
+
const index = (rels, stripExt) => {
|
|
126
|
+
const idx = {};
|
|
127
|
+
for (const rel of [...rels].sort()) {
|
|
128
|
+
const withoutExt = stripExt ? rel.replace(/\.md$/i, '') : rel;
|
|
129
|
+
for (const key of [withoutExt, basename(withoutExt)]) {
|
|
130
|
+
const k = key.toLowerCase();
|
|
131
|
+
if (!(k in idx))
|
|
132
|
+
idx[k] = rel; // 먼저 등록된(사전순 앞선) 것이 이긴다
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return idx;
|
|
136
|
+
};
|
|
137
|
+
return { notes: index(mdRels, true), assets: index(assetRels, false) };
|
|
138
|
+
}
|
|
139
|
+
/** 파일(rel) 기준으로 wikilink 대상을 상대경로로 풀어주는 resolver. */
|
|
140
|
+
export function vaultResolver(rel, vault) {
|
|
141
|
+
const dir = dirname(rel) === '.' ? '' : dirname(rel);
|
|
142
|
+
return (target, embed) => {
|
|
143
|
+
const key = target.toLowerCase().replace(/\.md$/i, '');
|
|
144
|
+
// 임베드(![[...]])는 대개 이미지, 링크([[...]])는 대개 노트 → 각자 먼저 찾아본다
|
|
145
|
+
const hit = embed
|
|
146
|
+
? (vault.assets[target.toLowerCase()] ?? vault.notes[key])
|
|
147
|
+
: (vault.notes[key] ?? vault.assets[target.toLowerCase()]);
|
|
148
|
+
if (!hit)
|
|
149
|
+
return null;
|
|
150
|
+
const r = relative(dir || '.', hit).split('\\').join('/');
|
|
151
|
+
return r || null;
|
|
152
|
+
};
|
|
28
153
|
}
|
|
29
154
|
const isReadme = (base) => /README\.md$/.test(base);
|
|
30
155
|
/** 폴더 경로 → 그 폴더의 대표 README(base 상대) 인덱스 */
|
package/dist/help.js
CHANGED
|
@@ -16,12 +16,14 @@ export function printHelp() {
|
|
|
16
16
|
const h = (s) => bold(s); // 섹션 제목
|
|
17
17
|
const o = (s) => cyan(s); // 옵션/명령
|
|
18
18
|
console.log(`${bold(cyan(`confluence-sync v${readPkgVersion()}`))}
|
|
19
|
-
${dim('
|
|
19
|
+
${dim('Confluence Cloud ↔ Markdown 양방향 동기화 CLI.')}
|
|
20
|
+
${dim('짧은 이름')} ${o('csync')} ${dim('로도 실행할 수 있습니다(아래 confluence-sync 를 csync 로 대체 가능).')}
|
|
20
21
|
|
|
21
22
|
${h('사용법:')}
|
|
22
23
|
${o('confluence-sync')} [옵션] [경로...] 문서를 동기화(md → Confluence)
|
|
23
24
|
${o('confluence-sync init')} 대화형으로 .env 설정 파일 생성
|
|
24
25
|
${o('confluence-sync pull <pageId|url>')} Confluence 페이지/폴더를 .md 로 가져오기(역방향)
|
|
26
|
+
${o('confluence-sync convert --to <형식>')} 이미 받은 .md 의 링크 표기를 바꾸기(로컬 전용)
|
|
25
27
|
${o('confluence-sync --help | --version')}
|
|
26
28
|
|
|
27
29
|
${h('동기화 옵션:')}
|
|
@@ -42,13 +44,45 @@ ${h('경로 인자:')}
|
|
|
42
44
|
예) ${o('confluence-sync 20-design')} ${o('confluence-sync 90-glossary.md')}
|
|
43
45
|
|
|
44
46
|
${h('pull(역방향) 옵션:')}
|
|
45
|
-
${o('--out <dir>')}
|
|
46
|
-
${o('--children')}
|
|
47
|
-
${o('--space')}
|
|
47
|
+
${o('--out <dir>')} .md 를 생성할 디렉토리(기본: 현재 폴더)
|
|
48
|
+
${o('--children')} 하위 페이지까지 재귀적으로 가져와 폴더 트리로 복원
|
|
49
|
+
${o('--space')} 대상 없이 스페이스(CONFLUENCE_SPACE_KEY) 홈페이지부터 전체를 가져옴
|
|
50
|
+
${o('--obsidian')} 내부 링크를 [[wikilink]] 로 출력(기본: 어디서나 열리는 상대 .md 링크)
|
|
48
51
|
(이미지는 항상 attachments/<문서명>/ 하위에 저장됨)
|
|
49
52
|
예) ${o('confluence-sync pull https://.../pages/12345/Title --out ./docs --children')}
|
|
50
53
|
${o('confluence-sync pull --space --out ./docs')}
|
|
51
54
|
|
|
55
|
+
${h('convert(이미 받아둔 .md 손보기) 옵션:')} ${dim('Confluence 호출·인증 없음')}
|
|
56
|
+
${o('--to obsidian')} 상대 .md 링크 → [[wikilink]]
|
|
57
|
+
${o('--to markdown')} [[wikilink]] · ![[embed]] → 상대 .md 링크 (되돌리기)
|
|
58
|
+
${o('--fix')} 옛 pull 결과 보정 — 코드블록 언어(java→plaintext)·CSS 잔해·
|
|
59
|
+
불필요한 \\ 이스케이프·리스트 사이 빈 줄
|
|
60
|
+
${o('--base <dir>')} 링크를 해석할 문서 트리 루트(기본: 준 경로들의 공통 상위 폴더)
|
|
61
|
+
${o('--out <dir>')} 원본을 두고 결과를 다른 트리에 쓰기(참조된 첨부도 함께 복사)
|
|
62
|
+
${o('--dry-run')} 바뀔 파일만 출력하고 쓰지 않음
|
|
63
|
+
|
|
64
|
+
파일·폴더를 여러 개 줄 수 있고, 안 주면 base 전체가 대상입니다.
|
|
65
|
+
${o('--out')} 없이 실행하면 ${dim('원본 파일을 덮어씁니다')}.
|
|
66
|
+
예) ${o('confluence-sync convert --fix ./docs --dry-run')}
|
|
67
|
+
${o('confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault')}
|
|
68
|
+
${o('confluence-sync convert --fix ./docs/가이드/설치.md --base ./docs')} ${dim('(파일 하나만)')}
|
|
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
|
+
|
|
78
|
+
${h('Obsidian:')}
|
|
79
|
+
pull 결과는 vault 에서 바로 열립니다. 함께 받은 페이지끼리의 내부 링크가 이어져 그래프·백링크가
|
|
80
|
+
동작하고, 각 문서의 frontmatter 에 pageId 가 남아 원본 페이지를 가리킵니다.
|
|
81
|
+
[[wikilink]] 표기를 원하면 ${o('pull --obsidian')}, 이미 받아둔 문서라면 ${o('convert --to obsidian')}.
|
|
82
|
+
|
|
83
|
+
push 는 vault 문법을 그대로 이해합니다: ${o('[[wikilink]]')} · ${o('![[embed]]')} · ${o('[[문서#헤딩]]')} · YAML frontmatter
|
|
84
|
+
(frontmatter 의 ${o('title')} 이 페이지 제목이 되고, ${o('pageId')} 가 있으면 매핑이 없어도 그 페이지를 갱신합니다).
|
|
85
|
+
|
|
52
86
|
${h('설정(.env):')}
|
|
53
87
|
실행 위치(cwd)의 .env 또는 셸 환경변수를 읽습니다.
|
|
54
88
|
필수: CONFLUENCE_BASE_URL, CONFLUENCE_EMAIL, CONFLUENCE_API_TOKEN, CONFLUENCE_SPACE_KEY
|
package/dist/html2md.js
CHANGED
|
@@ -22,7 +22,7 @@ const linkDest = (p) => (/\s/.test(p) ? `<${p}>` : p);
|
|
|
22
22
|
* (예: `**7️⃣**\-**1️⃣**` — 두 굵은 글씨 사이 텍스트 노드가 "-" 하나라 노드 시작으로 취급)
|
|
23
23
|
* 줄 맨 앞의 진짜 마커 이스케이프는 보존하고, 그 뒤(줄 중간)의 것만 되돌린다. 코드블록은 건드리지 않는다.
|
|
24
24
|
*/
|
|
25
|
-
function relaxEscapes(md) {
|
|
25
|
+
export function relaxEscapes(md) {
|
|
26
26
|
let inFence = false;
|
|
27
27
|
return md
|
|
28
28
|
.split('\n')
|
|
@@ -39,6 +39,8 @@ function relaxEscapes(md) {
|
|
|
39
39
|
.slice(head.length)
|
|
40
40
|
// 줄 중간의 - + . > 는 마커가 아니므로 이스케이프 불필요
|
|
41
41
|
.replace(/\\([-+.>])/g, '$1')
|
|
42
|
+
// 단어 내부 밑줄은 CommonMark 에서 강조가 아니다 (:11_eleven_square_blue: 같은 이모지 코드)
|
|
43
|
+
.replace(/(\w)\\_(?=\w)/g, '$1_')
|
|
42
44
|
// 링크가 아닌 대괄호 쌍(뒤에 ( 나 [ 가 오지 않음)은 이스케이프 불필요
|
|
43
45
|
.replace(/\\\[([^[\]\n]*)\\\](?!\s*[([])/g, '[$1]');
|
|
44
46
|
return head + rest;
|
package/dist/markdown.js
CHANGED
|
@@ -9,6 +9,8 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
9
9
|
import { resolve, relative, dirname, basename } from 'node:path';
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
11
|
import MarkdownIt from 'markdown-it';
|
|
12
|
+
import { resolveWikilinks } from './obsidian.js';
|
|
13
|
+
import { decodeAnchor, anchorMacro } from './anchors.js';
|
|
12
14
|
export const escapeXml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
13
15
|
export const hashOf = (s) => createHash('sha256').update(s).digest('hex');
|
|
14
16
|
/** markdown-it 이 퍼센트 인코딩한 링크/이미지 경로를 실제 경로로 디코드(실패 시 원본). */
|
|
@@ -21,19 +23,57 @@ const decodePath = (s) => {
|
|
|
21
23
|
}
|
|
22
24
|
};
|
|
23
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
|
+
});
|
|
24
31
|
// 렌더 규칙은 md 인스턴스에 한 번만 등록되므로, 현재 변환 컨텍스트를 모듈 변수로 공유한다.
|
|
25
|
-
let ctx =
|
|
32
|
+
let ctx = emptyCtx();
|
|
26
33
|
let baseDir = '';
|
|
27
|
-
/**
|
|
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. */
|
|
28
49
|
function resolveInternalLink(href) {
|
|
29
|
-
if (!href || /^(https?:|mailto
|
|
50
|
+
if (!href || /^(https?:|mailto:)/i.test(href))
|
|
30
51
|
return null;
|
|
31
|
-
|
|
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('#');
|
|
32
58
|
if (!pathPart || !/\.md$/i.test(pathPart))
|
|
33
59
|
return null;
|
|
34
60
|
const rel = relative(baseDir, resolve(baseDir, ctx.fileDir, decodePath(pathPart)));
|
|
35
|
-
|
|
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 };
|
|
36
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
|
+
};
|
|
37
77
|
md.renderer.rules.fence = (tokens, idx) => {
|
|
38
78
|
const t = tokens[idx];
|
|
39
79
|
const lang = (t.info || '').trim().split(/\s+/)[0];
|
|
@@ -43,12 +83,19 @@ md.renderer.rules.fence = (tokens, idx) => {
|
|
|
43
83
|
};
|
|
44
84
|
md.renderer.rules.link_open = (tokens, idx, opts, _env, self) => {
|
|
45
85
|
const href = tokens[idx].attrGet('href') || '';
|
|
46
|
-
const
|
|
47
|
-
|
|
86
|
+
const t = resolveInternalLink(href);
|
|
87
|
+
// 페이지도 앵커도 못 정하면 Confluence 링크로 만들 게 없다 → 원문 <a> 유지
|
|
88
|
+
if (t && (t.title || t.anchor)) {
|
|
48
89
|
ctx.linkStack.push(true);
|
|
49
90
|
ctx.internalLinks++;
|
|
50
|
-
|
|
51
|
-
|
|
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>`;
|
|
52
99
|
}
|
|
53
100
|
ctx.linkStack.push(false);
|
|
54
101
|
return self.renderToken(tokens, idx, opts);
|
|
@@ -83,12 +130,32 @@ export function splitTitleAndBody(markdown, fallback) {
|
|
|
83
130
|
lines.splice(i, 1);
|
|
84
131
|
return { title, body: lines.join('\n') };
|
|
85
132
|
}
|
|
86
|
-
/**
|
|
87
|
-
|
|
133
|
+
/**
|
|
134
|
+
* 본문 markdown 을 storage format 으로 변환(base 기준 상대경로로 링크·이미지 해석).
|
|
135
|
+
*/
|
|
136
|
+
export function toStorage(markdown, rel, titleIndex, base, opts = {}) {
|
|
88
137
|
baseDir = base;
|
|
89
|
-
ctx = {
|
|
90
|
-
|
|
91
|
-
|
|
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;
|
|
149
|
+
const storage = md.render(src);
|
|
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
|
+
};
|
|
92
159
|
}
|
|
93
160
|
/**
|
|
94
161
|
* 변경 감지용 문서 해시: 제목 + storage + 참조 로컬 이미지의 내용 해시.
|
package/dist/obsidian.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Obsidian 브릿지 — vault 관례와 이 도구의 표준 마크다운 사이를 잇는다.
|
|
3
|
+
*
|
|
4
|
+
* 원칙: **표준을 내보내고, 방언을 받아들인다.**
|
|
5
|
+
* - push: vault 에서 쓴 YAML frontmatter · [[wikilink]] · ![[embed]] 를 이해해 그대로 발행한다.
|
|
6
|
+
* - pull: Obsidian·GitHub·VS Code 어디서나 열리는 표준 상대 링크로 내보낸다
|
|
7
|
+
* (frontmatter 의 pageId 가 왕복 동기화의 앵커가 된다).
|
|
8
|
+
*
|
|
9
|
+
* frontmatter 는 평면 `key: value` 만 다룬다. 중첩·리스트가 필요할 만큼 복잡한 메타데이터는
|
|
10
|
+
* 이 도구의 관심사가 아니며, 알 수 없는 키는 건드리지 않고 보존한다.
|
|
11
|
+
*/
|
|
12
|
+
import { wikilinkAnchorToSlug } from './anchors.js';
|
|
13
|
+
const FENCE = /^\s*(?:```|~~~)/;
|
|
14
|
+
// [[대상]] · [[대상|별칭]] · [[대상#헤딩]] · ![[임베드]]
|
|
15
|
+
const WIKILINK = /(!?)\[\[([^\]|#]+)(#[^\]|]+)?(?:\|([^\]]+))?\]\]/g;
|
|
16
|
+
/**
|
|
17
|
+
* 문서 맨 앞의 YAML frontmatter 를 분리한다. 없으면 data 는 빈 객체.
|
|
18
|
+
* `key: value` 한 줄짜리만 인식하고, 값의 감싼 따옴표는 벗긴다.
|
|
19
|
+
*/
|
|
20
|
+
export function splitFrontmatter(raw) {
|
|
21
|
+
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
|
|
22
|
+
if (!m)
|
|
23
|
+
return { data: {}, body: raw };
|
|
24
|
+
const data = {};
|
|
25
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
26
|
+
const kv = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
|
|
27
|
+
if (!kv)
|
|
28
|
+
continue; // 중첩/리스트 등 평면이 아닌 줄은 무시(보존은 하지 않음)
|
|
29
|
+
data[kv[1]] = kv[2].trim().replace(/^["'](.*)["']$/, '$1');
|
|
30
|
+
}
|
|
31
|
+
return { data, body: raw.slice(m[0].length) };
|
|
32
|
+
}
|
|
33
|
+
/** 평면 frontmatter 블록 생성. 값이 비면 그 키는 생략한다. */
|
|
34
|
+
export function buildFrontmatter(data) {
|
|
35
|
+
const lines = Object.entries(data)
|
|
36
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
37
|
+
// 콜론·따옴표·선행 특수문자가 있으면 YAML 이 깨지므로 인용한다
|
|
38
|
+
.map(([k, v]) => `${k}: ${/[:#"'\n]|^\s|\s$/.test(v) ? JSON.stringify(v) : v}`);
|
|
39
|
+
return lines.length ? `---\n${lines.join('\n')}\n---\n\n` : '';
|
|
40
|
+
}
|
|
41
|
+
/** 공백이 있으면 <...> 로 감싼다(markdown-it 이 목적지를 통째로 파싱하도록). */
|
|
42
|
+
const dest = (p) => (/\s/.test(p) ? `<${p}>` : p);
|
|
43
|
+
/**
|
|
44
|
+
* 마크다운 본문에서 "산문" 부분에만 변환을 적용한다.
|
|
45
|
+
* 코드블록(``` / ~~~)과 인라인 코드(`...`) 안은 그대로 둔다 — 링크처럼 생긴 예제 코드를
|
|
46
|
+
* 망가뜨리지 않기 위해서다.
|
|
47
|
+
*/
|
|
48
|
+
function mapProse(md, fn) {
|
|
49
|
+
let inFence = false;
|
|
50
|
+
return md
|
|
51
|
+
.split('\n')
|
|
52
|
+
.map((line) => {
|
|
53
|
+
if (FENCE.test(line)) {
|
|
54
|
+
inFence = !inFence;
|
|
55
|
+
return line;
|
|
56
|
+
}
|
|
57
|
+
if (inFence)
|
|
58
|
+
return line;
|
|
59
|
+
// 홀수 인덱스 = 인라인 코드 스팬 → 원문 유지
|
|
60
|
+
return line
|
|
61
|
+
.split(/(`+[^`]*`+)/)
|
|
62
|
+
.map((seg, i) => (i % 2 ? seg : fn(seg)))
|
|
63
|
+
.join('');
|
|
64
|
+
})
|
|
65
|
+
.join('\n');
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* [[wikilink]] · ![[embed]] → 표준 마크다운 링크.
|
|
69
|
+
* 해석에 실패한 링크는 원문 그대로 남겨 (조용히 사라지지 않고) 눈에 띄게 한다.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveWikilinks(md, resolve) {
|
|
72
|
+
return mapProse(md, (s) => s.replace(WIKILINK, (whole, bang, target, hash, alias) => {
|
|
73
|
+
const embed = bang === '!';
|
|
74
|
+
const found = resolve(target.trim(), embed);
|
|
75
|
+
if (!found)
|
|
76
|
+
return whole; // 해석 실패 → 원문 보존
|
|
77
|
+
const label = (alias ?? target).trim();
|
|
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}` : ''))})`;
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
// [text](dest) — dest 는 <...> 로 감싸였을 수 있다. 앞의 ! 는 이미지 임베드.
|
|
87
|
+
const MD_LINK = /(!?)\[([^\]]*)\]\((<[^>]+>|[^()\s]+)\)/g;
|
|
88
|
+
/**
|
|
89
|
+
* 표준 마크다운 링크 → [[wikilink]] (resolveWikilinks 의 역변환).
|
|
90
|
+
* toWikilink 가 null 을 주면(= 외부 URL·이미지·대상 불명) 그 링크는 그대로 둔다.
|
|
91
|
+
* 이미지 임베드(`![...]()`)는 건드리지 않는다 — 표준 문법이 Obsidian 에서도 그대로 렌더된다.
|
|
92
|
+
*/
|
|
93
|
+
export function linksToWikilinks(md, toWikilink) {
|
|
94
|
+
return mapProse(md, (s) => s.replace(MD_LINK, (whole, bang, label, rawDest) => {
|
|
95
|
+
if (bang === '!')
|
|
96
|
+
return whole; // 이미지는 표준 링크 유지
|
|
97
|
+
const d = rawDest.replace(/^<(.*)>$/, '$1');
|
|
98
|
+
return toWikilink(d, label) ?? whole;
|
|
99
|
+
}));
|
|
100
|
+
}
|