@wonseok-han/confluence-sync 1.5.0 → 1.6.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 +63 -33
- package/dist/cli/args.js +14 -0
- package/dist/{help.js → cli/help.js} +11 -3
- package/dist/{init.js → cli/init.js} +1 -1
- package/dist/cli/main.js +38 -0
- package/dist/{confluence.js → confluence/client.js} +1 -1
- package/dist/{config.js → confluence/config.js} +1 -1
- package/dist/{convert.js → conversion/convert.js} +4 -4
- package/dist/{footnotes.js → conversion/footnotes.js} +1 -1
- package/dist/{pdf-pages.js → conversion/pdf-pages.js} +1 -1
- package/dist/{repair.js → conversion/repair.js} +2 -2
- package/dist/conversion/worker.js +9 -0
- package/dist/{docs.js → documents/catalog.js} +3 -1
- package/dist/{markdown.js → documents/markdown.js} +4 -1
- package/dist/{pull.js → sync/pull.js} +8 -8
- package/dist/sync/push.js +302 -0
- package/dist/sync/references.js +93 -0
- package/dist/sync/render.js +47 -0
- package/dist/sync.js +2 -348
- package/dist/web/assets/Monaco-LICENSE.txt +21 -0
- package/dist/web/assets/Monaco-ThirdPartyNotices.txt +448 -0
- package/dist/web/assets/app.css +1 -0
- package/dist/web/assets/app.js +939 -0
- package/dist/web/assets/editor.worker.js +30 -0
- package/dist/web/folder-dialog.js +44 -0
- package/dist/web/output.js +139 -0
- package/dist/web/server.js +346 -0
- package/dist/web/sync.js +226 -0
- package/dist/web/views/help.js +33 -0
- package/dist/web/views/sync-panel.js +26 -0
- package/dist/web/views/workspace.js +39 -0
- package/package.json +5 -2
- package/dist/render.js +0 -27
- /package/dist/{attachments.js → conversion/attachments.js} +0 -0
- /package/dist/{pdf-renderer.js → conversion/pdf-renderer.js} +0 -0
- /package/dist/{anchors.js → documents/anchors.js} +0 -0
- /package/dist/{html2md.js → documents/html-to-markdown.js} +0 -0
- /package/dist/{mermaid.js → documents/mermaid.js} +0 -0
- /package/dist/{obsidian.js → documents/obsidian.js} +0 -0
- /package/dist/{colors.js → shared/colors.js} +0 -0
- /package/dist/{ignore.js → sync/ignore.js} +0 -0
- /package/dist/{mapping.js → sync/mapping.js} +0 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/** Incremental Confluence push and local preview orchestration. */
|
|
2
|
+
import { positionalArgs } from '../cli/args.js';
|
|
3
|
+
import { existsSync, statSync } from 'node:fs';
|
|
4
|
+
import { resolve, relative } from 'node:path';
|
|
5
|
+
import { readEnv, requireEnv } from '../confluence/config.js';
|
|
6
|
+
import { docHash } from '../documents/markdown.js';
|
|
7
|
+
import { buildTreeRenderer } from './render.js';
|
|
8
|
+
import { collectMarkdown, buildFolderIndex, readDoc, parentKeyOf, containerKeyOf, sortForSync, resolveSelection, withParents, } from '../documents/catalog.js';
|
|
9
|
+
import { loadMapping, saveMapping } from './mapping.js';
|
|
10
|
+
import { createClient } from '../confluence/client.js';
|
|
11
|
+
import { buildIgnorer } from './ignore.js';
|
|
12
|
+
import { bold, dim, red, green, yellow, magenta, cyan, gray } from '../shared/colors.js';
|
|
13
|
+
export async function runPush(argv) {
|
|
14
|
+
const DRY_RUN = argv.includes('--dry-run');
|
|
15
|
+
const LIST = argv.includes('--list');
|
|
16
|
+
const REBUILD = argv.includes('--rebuild');
|
|
17
|
+
const FORCE = argv.includes('--force');
|
|
18
|
+
const VERIFY = argv.includes('--verify');
|
|
19
|
+
function optVal(name) {
|
|
20
|
+
const i = argv.indexOf(name);
|
|
21
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
|
22
|
+
}
|
|
23
|
+
/** 반복 가능한 옵션의 모든 값 수집(예: --exclude a --exclude b) */
|
|
24
|
+
function optVals(name) {
|
|
25
|
+
const out = [];
|
|
26
|
+
for (let i = 0; i < argv.length; i++)
|
|
27
|
+
if (argv[i] === name && i + 1 < argv.length)
|
|
28
|
+
out.push(argv[++i]);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
const EXCLUDES = optVals('--exclude');
|
|
32
|
+
const positionals = positionalArgs(argv);
|
|
33
|
+
const baseInput = optVal('--base') ?? process.env.CONFLUENCE_SYNC_BASE;
|
|
34
|
+
if (!baseInput) {
|
|
35
|
+
console.error(red('✗ 동기화 루트가 지정되지 않았습니다.') + '\n' +
|
|
36
|
+
' --base <dir> 인자 또는 env CONFLUENCE_SYNC_BASE 로 동기화할 디렉토리를 지정하세요.\n' +
|
|
37
|
+
` 예) ${cyan('confluence-sync --base ./docs')}\n` +
|
|
38
|
+
` 설정(.env)을 만들려면: ${cyan('confluence-sync init')}\n` +
|
|
39
|
+
` 전체 도움말: ${cyan('confluence-sync --help')}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
const BASE_DIR = resolve(baseInput);
|
|
43
|
+
// mapping 은 base 루트에 둔다(문서셋에 종속). --mapping 으로 위치 override 가능.
|
|
44
|
+
const mappingOpt = optVal('--mapping');
|
|
45
|
+
const MAPPING_PATH = mappingOpt ? resolve(mappingOpt) : resolve(BASE_DIR, '.confluence-sync.json');
|
|
46
|
+
const env = readEnv();
|
|
47
|
+
if (!existsSync(BASE_DIR) || !statSync(BASE_DIR).isDirectory()) {
|
|
48
|
+
console.error(red(`동기화 루트가 디렉토리가 아닙니다: ${BASE_DIR}`));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
if (!DRY_RUN && !LIST)
|
|
52
|
+
requireEnv(env); // list/dry 는 Confluence 호출이 없어 인증 불필요
|
|
53
|
+
const ignorer = buildIgnorer(BASE_DIR, EXCLUDES);
|
|
54
|
+
const collected = collectMarkdown(BASE_DIR).map((f) => relative(BASE_DIR, f).split('\\').join('/'));
|
|
55
|
+
const allRel = collected.filter((r) => !ignorer.ignores(r)); // 제외된 문서는 모든 단계에서 빠짐
|
|
56
|
+
const ignoredCount = collected.length - allRel.length;
|
|
57
|
+
const folderIndex = buildFolderIndex(allRel);
|
|
58
|
+
// 링크·섹션 링크 변환은 항상 트리 전체 기준이다. 선택 동기화여도 대상 문서의 제목·헤딩을 알아야 하고,
|
|
59
|
+
// 다른 문서가 이 문서의 섹션을 가리킬 수 있다. 제외된 문서는 링크 대상에서도 빠진다.
|
|
60
|
+
const { titles: titleIndex, render } = buildTreeRenderer(BASE_DIR, allRel, { referenceRoots: optVals('--reference-root'), baseUrl: env.baseUrl });
|
|
61
|
+
// 사용자가 실제로 고른 문서. 경로 인자가 없으면 전체가 대상이다.
|
|
62
|
+
const picked = positionals.length ? resolveSelection(allRel, positionals, BASE_DIR) : allRel;
|
|
63
|
+
const explicit = new Set(picked);
|
|
64
|
+
// 상위 README 를 함께 넣는 것은 **자식의 부모 pageId 를 얻기 위해서**다(발행 대상이 아니다).
|
|
65
|
+
const selected = positionals.length ? withParents(picked, folderIndex) : allRel;
|
|
66
|
+
const files = sortForSync(selected);
|
|
67
|
+
/** 딸려온 상위 README 인가 — 이미 페이지가 있으면 내용은 건드리지 않는다. */
|
|
68
|
+
const isCarriedParent = (rel) => !explicit.has(rel);
|
|
69
|
+
const scope = positionals.length
|
|
70
|
+
? `선택 ${explicit.size}/${allRel.length}건` +
|
|
71
|
+
(files.length > explicit.size ? dim(` (+상위 ${files.length - explicit.size})`) : '')
|
|
72
|
+
: `${files.length}건`;
|
|
73
|
+
const ignoredNote = ignoredCount ? dim(` (제외 ${ignoredCount})`) : '';
|
|
74
|
+
console.log(`${dim('base:')} ${cyan(BASE_DIR)}\n${dim('대상:')} ${scope}${ignoredNote}`);
|
|
75
|
+
// ---- 경로 헬퍼 ----
|
|
76
|
+
const isReadmeFile = (p) => /README\.md$/.test(p);
|
|
77
|
+
const segOf = (s) => (s === '' ? 0 : s.split('/').length);
|
|
78
|
+
const dirOf = (p) => p.split('/').slice(0, -1).join('/');
|
|
79
|
+
const parentDirOf = (d) => (d.includes('/') ? d.slice(0, d.lastIndexOf('/')) : '');
|
|
80
|
+
const fileDepth = (rel) => (isReadmeFile(rel) ? Math.max(0, segOf(dirOf(rel)) - 1) : segOf(dirOf(rel)));
|
|
81
|
+
const folderDepth = (d) => Math.max(0, segOf(d) - 1);
|
|
82
|
+
// 파일의 README 없는(=폴더로 만들) 조상 dir들, shallow→deep
|
|
83
|
+
const ancestorFolderDirs = (rel) => {
|
|
84
|
+
const out = [];
|
|
85
|
+
for (let d = dirOf(rel); d !== ''; d = parentDirOf(d))
|
|
86
|
+
if (!folderIndex[d])
|
|
87
|
+
out.unshift(d);
|
|
88
|
+
return out;
|
|
89
|
+
};
|
|
90
|
+
// --list: base에서 인식된 문서·제목·계층만 출력(Confluence 호출 없음)
|
|
91
|
+
if (LIST) {
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
for (const rel of files) {
|
|
94
|
+
for (const d of ancestorFolderDirs(rel))
|
|
95
|
+
if (!seen.has(d)) {
|
|
96
|
+
seen.add(d);
|
|
97
|
+
console.log(` ${' '.repeat(folderDepth(d))}${cyan(`📁 ${d}/`)}`);
|
|
98
|
+
}
|
|
99
|
+
console.log(` ${' '.repeat(fileDepth(rel))}${rel} ${dim('—')} ${cyan(`"${titleIndex[rel]}"`)}`);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const mapping = loadMapping(MAPPING_PATH);
|
|
104
|
+
if (DRY_RUN) {
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
for (const rel of files) {
|
|
107
|
+
for (const d of ancestorFolderDirs(rel))
|
|
108
|
+
if (!seen.has(d)) {
|
|
109
|
+
seen.add(d);
|
|
110
|
+
const fex = mapping[`${d}/`];
|
|
111
|
+
console.log(` [${fex?.pageId ? gray('폴더') : green('폴더+')}] 📁 ${d}/`);
|
|
112
|
+
}
|
|
113
|
+
const ex0 = mapping[rel];
|
|
114
|
+
// 딸려온 상위 README 는 pageId 만 쓰고 지나간다 — 고르지 않은 문서를 발행하지 않는다
|
|
115
|
+
if (isCarriedParent(rel) && ex0?.pageId) {
|
|
116
|
+
console.log(` [${gray('상위')}] ${rel} ${dim(`(pageId #${ex0.pageId} 만 사용)`)}`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const { title, body, fm } = readDoc(BASE_DIR, rel);
|
|
120
|
+
const r = render(rel, body, title);
|
|
121
|
+
for (const link of r.referenceLinks)
|
|
122
|
+
console.log(dim(` 참조 링크: ${link}`));
|
|
123
|
+
for (const warning of r.linkWarnings)
|
|
124
|
+
console.error(yellow(` ⚠ ${rel}: ${warning}`));
|
|
125
|
+
const hash = docHash(title, r);
|
|
126
|
+
const ex = mapping[rel];
|
|
127
|
+
// 매핑엔 없지만 frontmatter 에 pageId 가 있으면 신규 생성이 아니라 기존 페이지에 연결된다
|
|
128
|
+
const status = !ex?.pageId
|
|
129
|
+
? (fm.pageId ? cyan('연결') : green('신규'))
|
|
130
|
+
: ex.hash === hash ? gray('동일') : yellow('변경');
|
|
131
|
+
const pk = parentKeyOf(rel, folderIndex);
|
|
132
|
+
const anchorNote = r.anchorLinks || r.anchorsPlaced
|
|
133
|
+
? `, 섹션링크: ${r.anchorLinks}, 앵커: ${r.anchorsPlaced}` : '';
|
|
134
|
+
console.log(` [${status}] ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}, 내부링크: ${r.internalLinks}${anchorNote}, 이미지: ${r.images.length})`)}`);
|
|
135
|
+
if (r.deadAnchors.length) {
|
|
136
|
+
console.error(yellow(` ⚠ 대응 헤딩 없는 앵커 ${r.deadAnchors.length}개: `) + dim(r.deadAnchors.slice(0, 3).join(' ')));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
console.log('\n' + dim('--dry-run: 실제 호출 없음.'));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const client = createClient({ baseUrl: env.baseUrl, email: env.email, token: env.token }, { force: FORCE, verify: VERIFY });
|
|
143
|
+
const spaceId = await client.getSpaceId(env.spaceKey);
|
|
144
|
+
if (REBUILD) {
|
|
145
|
+
await client.deleteAll(mapping);
|
|
146
|
+
saveMapping(MAPPING_PATH, {});
|
|
147
|
+
}
|
|
148
|
+
const work = REBUILD ? {} : mapping;
|
|
149
|
+
let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0, linked = 0, carried = 0;
|
|
150
|
+
const rendered = new Map();
|
|
151
|
+
const recreatedTitles = new Set(); // 이번에 재생성된 문서의 제목
|
|
152
|
+
const touched = new Set(); // 이번에 발행(생성/갱신/재생성)된 문서
|
|
153
|
+
const handledFolders = new Set(); // 이번 실행에서 처리한 폴더 dir
|
|
154
|
+
// README 없는 폴더를 Confluence 폴더로 생성(없으면)하고 work 에 등록. 성공 여부 반환.
|
|
155
|
+
const ensureFolder = async (dir) => {
|
|
156
|
+
const key = `${dir}/`;
|
|
157
|
+
const pcKey = containerKeyOf(parentDirOf(dir), folderIndex);
|
|
158
|
+
const parentId = pcKey ? work[pcKey]?.pageId : env.parentId;
|
|
159
|
+
if (pcKey && !parentId) {
|
|
160
|
+
console.error(yellow(` ✗ 폴더 건너뜀 ${dir}/ (부모 '${pcKey}' 가 아직 없음)`));
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
const ex = work[key];
|
|
164
|
+
if (ex?.pageId && ex.type === 'folder' && (await client.getContentOrNull(ex.pageId))) {
|
|
165
|
+
return true; // 이미 존재
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const title = dir.split('/').pop();
|
|
169
|
+
const fid = await client.createFolder(spaceId, title, parentId);
|
|
170
|
+
work[key] = { pageId: fid, title, type: 'folder' };
|
|
171
|
+
saveMapping(MAPPING_PATH, work);
|
|
172
|
+
console.log(` ${cyan('📁 폴더')} ${dir}/ ${dim(`(부모: ${pcKey ?? 'ROOT'})`)}`);
|
|
173
|
+
foldersMade++;
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
console.error(red(` ✗ 폴더 생성 실패 ${dir}/`) + `\n${e.message}`);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
for (const rel of files) {
|
|
182
|
+
// 상위 README-less 폴더들 보장(shallow→deep)
|
|
183
|
+
let folderOk = true;
|
|
184
|
+
for (const d of ancestorFolderDirs(rel)) {
|
|
185
|
+
if (!handledFolders.has(d)) {
|
|
186
|
+
handledFolders.add(d);
|
|
187
|
+
if (!(await ensureFolder(d)))
|
|
188
|
+
folderOk = false;
|
|
189
|
+
}
|
|
190
|
+
else if (!work[`${d}/`]?.pageId)
|
|
191
|
+
folderOk = false;
|
|
192
|
+
if (!folderOk)
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
if (!folderOk) {
|
|
196
|
+
console.error(yellow(` ✗ 건너뜀 ${rel} (상위 폴더 미생성)`));
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// 딸려온 상위 README 는 자식의 부모 pageId 를 얻으려고 들어온 것이다.
|
|
200
|
+
// 이미 페이지가 있으면 내용은 건드리지 않는다 — `csync <파일>` 이 고르지 않은 문서를 발행하면 안 된다.
|
|
201
|
+
// (페이지가 아직 없으면 자식을 붙일 곳이 없으므로 아래에서 생성한다)
|
|
202
|
+
if (isCarriedParent(rel) && work[rel]?.pageId) {
|
|
203
|
+
console.log(` ${gray('· 상위')} ${rel} ${dim(`(pageId #${work[rel].pageId} 만 사용)`)}`);
|
|
204
|
+
carried++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const { title, body, fm } = readDoc(BASE_DIR, rel);
|
|
208
|
+
const r = render(rel, body, title);
|
|
209
|
+
for (const link of r.referenceLinks)
|
|
210
|
+
console.log(dim(` 참조 링크: ${link}`));
|
|
211
|
+
for (const warning of r.linkWarnings)
|
|
212
|
+
console.error(yellow(` ⚠ ${rel}: ${warning}`));
|
|
213
|
+
const hash = docHash(title, r);
|
|
214
|
+
const pk = parentKeyOf(rel, folderIndex);
|
|
215
|
+
if (r.deadAnchors.length) {
|
|
216
|
+
console.error(yellow(` ⚠ 대응 헤딩 없는 앵커 ${rel}`) + dim(` ${r.deadAnchors.slice(0, 3).join(' ')}${r.deadAnchors.length > 3 ? ` 외 ${r.deadAnchors.length - 3}` : ''}`));
|
|
217
|
+
}
|
|
218
|
+
const parentId = pk ? work[pk]?.pageId : env.parentId;
|
|
219
|
+
rendered.set(rel, { ...r, title, hash, parentId });
|
|
220
|
+
if (pk && !parentId) {
|
|
221
|
+
console.error(yellow(` ✗ 건너뜀 ${rel} (부모 '${pk}' 가 아직 없음)`));
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
// pull 로 받은 문서(frontmatter 에 pageId)는 매핑이 없어도 원본 페이지에 다시 연결한다.
|
|
225
|
+
// 매핑 파일을 잃었거나 vault 를 다른 곳에 복제한 경우 중복 생성을 막아준다.
|
|
226
|
+
if (!work[rel]?.pageId && fm.pageId) {
|
|
227
|
+
try {
|
|
228
|
+
if (await client.getContentOrNull(fm.pageId)) {
|
|
229
|
+
work[rel] = { pageId: fm.pageId, title };
|
|
230
|
+
saveMapping(MAPPING_PATH, work);
|
|
231
|
+
console.log(` ${cyan('🔗 연결')} ${rel} ${dim(`→ 기존 페이지 #${fm.pageId}`)}`);
|
|
232
|
+
linked++;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
console.error(yellow(` ⚠ frontmatter 의 pageId #${fm.pageId} 를 찾을 수 없습니다 → 새로 생성 (${rel})`));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
console.error(yellow(` ⚠ pageId 확인 실패 #${fm.pageId}: ${e.message}`));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const result = await client.upsertPage(work, spaceId, rel, title, r.storage, hash, parentId);
|
|
244
|
+
saveMapping(MAPPING_PATH, work);
|
|
245
|
+
if (result === 'skipped') {
|
|
246
|
+
console.log(` ${gray('= 변경없음')} ${rel}`);
|
|
247
|
+
skipped++;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const imgOk = await client.uploadImages(work[rel].pageId, r.images, BASE_DIR);
|
|
251
|
+
const imgNote = r.images.length ? `, 이미지 ${imgOk}/${r.images.length}` : '';
|
|
252
|
+
const label = result === 'created' ? green('+ 생성')
|
|
253
|
+
: result === 'recreated' ? magenta('♻ 재생성(삭제 복구)')
|
|
254
|
+
: yellow('↻ 갱신');
|
|
255
|
+
console.log(` ${label} ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}${imgNote})`)}`);
|
|
256
|
+
if (result === 'created')
|
|
257
|
+
created++;
|
|
258
|
+
else if (result === 'recreated') {
|
|
259
|
+
recreated++;
|
|
260
|
+
recreatedTitles.add(title);
|
|
261
|
+
}
|
|
262
|
+
else
|
|
263
|
+
updated++;
|
|
264
|
+
touched.add(rel);
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
console.error(red(` ✗ 실패 ${rel}`) + `\n${e.message}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// 링크 정합성: 재생성된 문서를 가리키던, 이번에 발행 안 된(스킵된) 문서를 강제 재발행해 링크를 새 페이지로 재연결
|
|
271
|
+
if (recreatedTitles.size) {
|
|
272
|
+
for (const rel of files) {
|
|
273
|
+
if (touched.has(rel))
|
|
274
|
+
continue;
|
|
275
|
+
const r = rendered.get(rel);
|
|
276
|
+
if (!r || !work[rel]?.pageId)
|
|
277
|
+
continue;
|
|
278
|
+
if (!r.linkedTitles.some((t) => recreatedTitles.has(t)))
|
|
279
|
+
continue;
|
|
280
|
+
try {
|
|
281
|
+
await client.upsertPage(work, spaceId, rel, r.title, r.storage, r.hash, r.parentId, true);
|
|
282
|
+
saveMapping(MAPPING_PATH, work);
|
|
283
|
+
await client.uploadImages(work[rel].pageId, r.images, BASE_DIR);
|
|
284
|
+
console.log(` ${cyan('🔗 링크 재연결')} ${rel} ${dim('→')} ${cyan(`"${r.title}"`)}`);
|
|
285
|
+
relinked++;
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
console.error(red(` ✗ 링크 재연결 실패 ${rel}`) + `\n${e.message}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
console.log('\n' + bold('완료') +
|
|
293
|
+
` ${green(`생성 ${created}`)}` +
|
|
294
|
+
` ${yellow(`갱신 ${updated}`)}` +
|
|
295
|
+
` ${magenta(`재생성 ${recreated}`)}` +
|
|
296
|
+
` ${cyan(`링크재연결 ${relinked}`)}` +
|
|
297
|
+
` ${gray(`변경없음 ${skipped}`)}` +
|
|
298
|
+
(linked ? ` ${cyan(`연결 ${linked}`)}` : '') +
|
|
299
|
+
(foldersMade ? ` ${cyan(`폴더 ${foldersMade}`)}` : '') +
|
|
300
|
+
(carried ? ` ${gray(`상위 ${carried}`)}` : '') +
|
|
301
|
+
` ${dim(`대상 ${files.length - carried}`)}`);
|
|
302
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Read-only link lookup across independently synchronized document roots. */
|
|
2
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { parse } from 'dotenv';
|
|
5
|
+
import { collectMarkdown } from '../documents/catalog.js';
|
|
6
|
+
import { buildIgnorer } from './ignore.js';
|
|
7
|
+
import { MAPPING_FILE } from './mapping.js';
|
|
8
|
+
const key = (s) => s.normalize('NFC').toLowerCase();
|
|
9
|
+
const slash = (s) => s.split('\\').join('/');
|
|
10
|
+
const decode = (s) => { try {
|
|
11
|
+
return decodeURIComponent(s);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return s;
|
|
15
|
+
} };
|
|
16
|
+
export function referenceRoots(base, roots) {
|
|
17
|
+
const own = realpathSync(base);
|
|
18
|
+
return [...new Set(roots.map(root => {
|
|
19
|
+
const path = realpathSync(resolve(root));
|
|
20
|
+
if (!statSync(path).isDirectory() || dirname(path) === path)
|
|
21
|
+
throw new Error('참조 문서 폴더를 확인하세요: ' + root);
|
|
22
|
+
const rel = slash(relative(own, path));
|
|
23
|
+
if (rel && rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel))
|
|
24
|
+
throw new Error('참조 폴더는 업로드 기준 폴더 밖에 있어야 합니다: ' + root);
|
|
25
|
+
return path;
|
|
26
|
+
}))].filter(root => root !== own);
|
|
27
|
+
}
|
|
28
|
+
export function buildReferenceLookup(base, rels, roots, baseUrl) {
|
|
29
|
+
const candidates = rels.map(rel => ({ root: base, rel, local: true }));
|
|
30
|
+
const server = baseUrl?.replace(/\/$/, '');
|
|
31
|
+
for (const root of referenceRoots(base, roots)) {
|
|
32
|
+
const envPath = join(root, '.env');
|
|
33
|
+
const configured = existsSync(envPath) ? parse(readFileSync(envPath)).CONFLUENCE_BASE_URL?.replace(/\/$/, '') : undefined;
|
|
34
|
+
if (configured && configured !== server)
|
|
35
|
+
throw new Error('참조 폴더의 Confluence 서버가 현재 설정과 다릅니다: ' + root);
|
|
36
|
+
let mapping = {};
|
|
37
|
+
const path = join(root, MAPPING_FILE);
|
|
38
|
+
if (existsSync(path)) {
|
|
39
|
+
mapping = JSON.parse(readFileSync(path, 'utf8'));
|
|
40
|
+
if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping)
|
|
41
|
+
|| Object.values(mapping).some(v => !v || typeof v.pageId !== 'string'))
|
|
42
|
+
throw new Error('참조 페이지 매핑이 올바르지 않습니다: ' + path);
|
|
43
|
+
}
|
|
44
|
+
const ignorer = buildIgnorer(root, []);
|
|
45
|
+
for (const file of collectMarkdown(root)) {
|
|
46
|
+
// Overlapping roots must not create duplicate candidates or export ignored local documents.
|
|
47
|
+
const fromBase = slash(relative(realpathSync(base), file));
|
|
48
|
+
if (fromBase !== '..' && !fromBase.startsWith('../') && !isAbsolute(fromBase))
|
|
49
|
+
continue;
|
|
50
|
+
const rel = slash(relative(root, file));
|
|
51
|
+
if (!ignorer.ignores(rel))
|
|
52
|
+
candidates.push({ root, rel, local: false, pageId: mapping[rel]?.pageId });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const unique = [...new Map(candidates.map(c => [realpathSync(join(c.root, c.rel)), c])).values()];
|
|
56
|
+
function find(from, raw, wiki) {
|
|
57
|
+
const target = decode(raw).replace(/\.md$/i, '') + '.md';
|
|
58
|
+
const exact = wiki ? target : slash(relative(base, resolve(base, dirname(from), target)));
|
|
59
|
+
const local = unique.filter(c => c.local && key(c.rel) === key(exact));
|
|
60
|
+
if (local.length)
|
|
61
|
+
return local;
|
|
62
|
+
const refs = unique.filter(c => !c.local && key(c.rel) === key(target));
|
|
63
|
+
if (refs.length)
|
|
64
|
+
return refs;
|
|
65
|
+
return unique.filter(c => key(basename(c.rel)) === key(basename(slash(target))));
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
localTarget(from, target, wiki) {
|
|
69
|
+
const hits = find(from, target, wiki);
|
|
70
|
+
return hits.length === 1 && hits[0].local ? hits[0].rel : null;
|
|
71
|
+
},
|
|
72
|
+
resolve(from, target, wiki, warn, linked = () => { }) {
|
|
73
|
+
const hits = find(from, target, wiki);
|
|
74
|
+
if (hits.length !== 1) {
|
|
75
|
+
warn(hits.length ? `중복 문서 링크: ${target} → ${hits.map(c => join(c.root, c.rel)).join(', ')}` : `대상 없는 문서 링크: ${target}`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const hit = hits[0];
|
|
79
|
+
if (hit.local)
|
|
80
|
+
return slash(relative(dirname(from), hit.rel));
|
|
81
|
+
if (!hit.pageId || !/^\d+$/.test(hit.pageId)) {
|
|
82
|
+
warn(`게시 매핑 없는 문서 링크: ${target} → ${join(hit.root, hit.rel)}`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!server || !/^https?:\/\//i.test(server)) {
|
|
86
|
+
warn(`참조 링크에 CONFLUENCE_BASE_URL이 필요합니다: ${target}`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
linked(`${target} → pageId ${hit.pageId} (${join(hit.root, hit.rel)})`);
|
|
90
|
+
return `${server}/pages/viewpage.action?pageId=${encodeURIComponent(hit.pageId)}`;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { buildReferenceLookup } from './references.js';
|
|
2
|
+
/**
|
|
3
|
+
* 문서 트리 하나를 storage format 으로 옮기는 렌더러.
|
|
4
|
+
*
|
|
5
|
+
* 링크·섹션 링크는 **트리 전체**를 알아야 풀린다(다른 문서의 제목과 헤딩을 참조하므로),
|
|
6
|
+
* 그래서 문서 하나가 아니라 트리 단위로 만든다.
|
|
7
|
+
*
|
|
8
|
+
* push(sync)와 pull 의 매핑 기록이 **같은 문서에 같은 해시**를 내야 하므로 —
|
|
9
|
+
* 어긋나면 pull 직후 push 가 전부 "변경"으로 보인다 — 두 곳이 이 함수 하나를 함께 쓴다.
|
|
10
|
+
*/
|
|
11
|
+
import { relative } from 'node:path';
|
|
12
|
+
import { toStorage } from '../documents/markdown.js';
|
|
13
|
+
import { buildDocIndex, buildVault, collectAssets, vaultResolver, resolveAnchorTargets, } from '../documents/catalog.js';
|
|
14
|
+
export function buildTreeRenderer(baseDir, rels, options) {
|
|
15
|
+
const { titles, anchors, docs } = buildDocIndex(rels, baseDir);
|
|
16
|
+
// Obsidian [[wikilink]] 해석용 이름 인덱스
|
|
17
|
+
const vault = buildVault(rels, collectAssets(baseDir).map((f) => relative(baseDir, f)));
|
|
18
|
+
// 누가 어느 헤딩을 가리키는지 먼저 모은다 — 그 헤딩에만 Anchor 매크로를 심는다.
|
|
19
|
+
const lookup = options ? buildReferenceLookup(baseDir, rels, options.referenceRoots, options.baseUrl) : null;
|
|
20
|
+
const anchorTargets = resolveAnchorTargets(docs, vault, lookup?.localTarget);
|
|
21
|
+
const render = (rel, body, title) => {
|
|
22
|
+
const warnings = new Set(), references = new Set();
|
|
23
|
+
const linked = (message) => references.add(message);
|
|
24
|
+
const warn = (message) => warnings.add(message);
|
|
25
|
+
const ordinary = vaultResolver(rel, vault);
|
|
26
|
+
const result = toStorage(body, rel, titles, baseDir, {
|
|
27
|
+
resolveLink: (target, embed) => {
|
|
28
|
+
if (!lookup || embed || !!vault.assets[target.toLowerCase()])
|
|
29
|
+
return ordinary(target, embed);
|
|
30
|
+
return lookup.resolve(rel, target, true, warn, linked);
|
|
31
|
+
},
|
|
32
|
+
rewriteHref: lookup ? href => {
|
|
33
|
+
if (/^[a-z][a-z0-9+.-]*:|^\/\//i.test(href))
|
|
34
|
+
return href;
|
|
35
|
+
const index = href.indexOf('#'), path = index < 0 ? href : href.slice(0, index);
|
|
36
|
+
if (!/\.md$/i.test(path))
|
|
37
|
+
return href;
|
|
38
|
+
const found = lookup.resolve(rel, path, false, warn, linked);
|
|
39
|
+
return found ? found + (index < 0 ? '' : href.slice(index)) : href;
|
|
40
|
+
} : undefined,
|
|
41
|
+
anchorIndex: anchors, title,
|
|
42
|
+
anchorNames: anchorTargets[rel], bodySlugs: docs[rel]?.bodySlugs,
|
|
43
|
+
});
|
|
44
|
+
return { ...result, linkWarnings: [...warnings], referenceLinks: [...references] };
|
|
45
|
+
};
|
|
46
|
+
return { titles, docs, render };
|
|
47
|
+
}
|