atl-fetch 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.
@@ -0,0 +1,476 @@
1
+ /**
2
+ * ADF(Atlassian Document Format)→ HTML 変換
3
+ *
4
+ * Markdown 変換の中間形式として HTML を生成する
5
+ */
6
+ /**
7
+ * HTML 特殊文字をエスケープする
8
+ *
9
+ * @param text エスケープ対象の文字列
10
+ * @returns エスケープ済み文字列
11
+ */
12
+ export const escapeHtml = (text) => {
13
+ return text
14
+ .replace(/&/g, '&')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;')
17
+ .replace(/"/g, '&quot;')
18
+ .replace(/'/g, '&#39;');
19
+ };
20
+ /**
21
+ * ADF マークを HTML タグで囲む
22
+ *
23
+ * @param text 対象のテキスト
24
+ * @param marks 適用するマーク配列
25
+ * @returns マークを適用した HTML
26
+ */
27
+ export const applyMarksToHtml = (text, marks) => {
28
+ let result = text;
29
+ for (const mark of marks) {
30
+ switch (mark.type) {
31
+ case 'strong':
32
+ result = `<strong>${result}</strong>`;
33
+ break;
34
+ case 'em':
35
+ result = `<em>${result}</em>`;
36
+ break;
37
+ case 'code':
38
+ result = `<code>${result}</code>`;
39
+ break;
40
+ case 'strike':
41
+ result = `<s>${result}</s>`;
42
+ break;
43
+ case 'underline':
44
+ result = `<u>${result}</u>`;
45
+ break;
46
+ case 'link': {
47
+ const href = mark.attrs?.['href'];
48
+ if (typeof href === 'string') {
49
+ result = `<a href="${escapeHtml(href)}">${result}</a>`;
50
+ }
51
+ break;
52
+ }
53
+ case 'textColor': {
54
+ const color = mark.attrs?.['color'];
55
+ if (typeof color === 'string') {
56
+ result = `<span style="color: ${escapeHtml(color)}">${result}</span>`;
57
+ }
58
+ break;
59
+ }
60
+ case 'subsup': {
61
+ const subType = mark.attrs?.['type'];
62
+ if (subType === 'sub') {
63
+ result = `<sub>${result}</sub>`;
64
+ }
65
+ else if (subType === 'sup') {
66
+ result = `<sup>${result}</sup>`;
67
+ }
68
+ break;
69
+ }
70
+ case 'backgroundColor': {
71
+ const bgColor = mark.attrs?.['color'];
72
+ if (typeof bgColor === 'string') {
73
+ result = `<span style="background-color: ${escapeHtml(bgColor)}">${result}</span>`;
74
+ }
75
+ break;
76
+ }
77
+ // 未知のマークタイプは無視
78
+ default:
79
+ break;
80
+ }
81
+ }
82
+ return result;
83
+ };
84
+ /**
85
+ * ADF ノードを HTML に変換する
86
+ *
87
+ * @param node ADF ノード
88
+ * @param attachmentPaths 添付ファイル ID → ローカルパスのマッピング
89
+ * @returns HTML 文字列
90
+ */
91
+ export const convertAdfNodeToHtml = (node, attachmentPaths) => {
92
+ // テキストノードの場合
93
+ if (node.type === 'text' && node.text !== undefined) {
94
+ const escapedText = escapeHtml(node.text);
95
+ if (node.marks !== undefined && node.marks.length > 0) {
96
+ return applyMarksToHtml(escapedText, node.marks);
97
+ }
98
+ return escapedText;
99
+ }
100
+ // hardBreak の場合
101
+ if (node.type === 'hardBreak') {
102
+ return '<br>';
103
+ }
104
+ // rule(水平線)の場合
105
+ if (node.type === 'rule') {
106
+ return '<hr>';
107
+ }
108
+ // メンションの場合
109
+ if (node.type === 'mention' && node.attrs !== undefined) {
110
+ const text = node.attrs['text'];
111
+ if (typeof text === 'string') {
112
+ return escapeHtml(text);
113
+ }
114
+ return '@ユーザー';
115
+ }
116
+ // 絵文字の場合
117
+ if (node.type === 'emoji' && node.attrs !== undefined) {
118
+ const text = node.attrs['text'];
119
+ const shortName = node.attrs['shortName'];
120
+ if (typeof text === 'string') {
121
+ return text;
122
+ }
123
+ if (typeof shortName === 'string') {
124
+ return shortName;
125
+ }
126
+ return '';
127
+ }
128
+ // date ノードの場合
129
+ if (node.type === 'date' && node.attrs?.['timestamp'] !== undefined) {
130
+ const timestamp = node.attrs['timestamp'];
131
+ const parsed = Number.parseInt(String(timestamp), 10);
132
+ if (Number.isNaN(parsed)) {
133
+ return '';
134
+ }
135
+ const date = new Date(parsed);
136
+ const formatted = date.toISOString().split('T')[0];
137
+ return `<time datetime="${formatted}">${formatted}</time>`;
138
+ }
139
+ // status ノードの場合
140
+ if (node.type === 'status' && node.attrs !== undefined) {
141
+ const color = node.attrs['color'];
142
+ const text = node.attrs['text'];
143
+ const colorEmojiMap = {
144
+ blue: '🔵',
145
+ green: '🟢',
146
+ neutral: '⚪',
147
+ purple: '🟣',
148
+ red: '🔴',
149
+ yellow: '🟡',
150
+ };
151
+ const emoji = typeof color === 'string' && colorEmojiMap[color] !== undefined ? colorEmojiMap[color] : '⚪';
152
+ const statusText = typeof text === 'string' ? text : '';
153
+ return `[${emoji} ${statusText}]`;
154
+ }
155
+ // media ノードの場合(添付ファイル)
156
+ if (node.type === 'media' && node.attrs !== undefined) {
157
+ const mediaId = node.attrs['id'];
158
+ const mediaType = node.attrs['type'];
159
+ // border マークのチェック
160
+ const borderMark = node.marks?.find((m) => m.type === 'border');
161
+ let borderStyle = '';
162
+ if (borderMark?.attrs !== undefined) {
163
+ const borderSize = typeof borderMark.attrs['size'] === 'number' ? borderMark.attrs['size'] : 1;
164
+ const borderColor = typeof borderMark.attrs['color'] === 'string' ? borderMark.attrs['color'] : '#000000';
165
+ borderStyle = ` style="border: ${borderSize}px solid ${escapeHtml(borderColor)}"`;
166
+ }
167
+ if (typeof mediaId === 'string' && attachmentPaths?.[mediaId] !== undefined) {
168
+ const localPath = attachmentPaths[mediaId];
169
+ const alt = typeof node.attrs['alt'] === 'string' ? node.attrs['alt'] : mediaId;
170
+ return `<img src="${escapeHtml(localPath)}" alt="${escapeHtml(alt)}"${borderStyle}>`;
171
+ }
172
+ // 外部リンクの場合
173
+ if (mediaType === 'external' || mediaType === 'link') {
174
+ const url = node.attrs['url'];
175
+ if (typeof url === 'string') {
176
+ return `<img src="${escapeHtml(url)}" alt=""${borderStyle}>`;
177
+ }
178
+ }
179
+ // マッピングがない場合はプレースホルダー
180
+ return '[添付ファイル]';
181
+ }
182
+ // mediaInline ノードの場合(インラインメディア)
183
+ if (node.type === 'mediaInline' && node.attrs !== undefined) {
184
+ const mediaId = node.attrs['id'];
185
+ if (typeof mediaId === 'string' && attachmentPaths?.[mediaId] !== undefined) {
186
+ const localPath = attachmentPaths[mediaId];
187
+ const alt = typeof node.attrs['alt'] === 'string' ? node.attrs['alt'] : mediaId;
188
+ return `<img src="${escapeHtml(localPath)}" alt="${escapeHtml(alt)}">`;
189
+ }
190
+ // マッピングがない場合はプレースホルダー
191
+ return '[添付ファイル]';
192
+ }
193
+ // mediaSingle(メディアコンテナ)の場合
194
+ if (node.type === 'mediaSingle' && node.content !== undefined) {
195
+ return node.content.map((child) => convertAdfNodeToHtml(child, attachmentPaths)).join('');
196
+ }
197
+ // mediaGroup の場合
198
+ if (node.type === 'mediaGroup' && node.content !== undefined) {
199
+ return node.content.map((child) => convertAdfNodeToHtml(child, attachmentPaths)).join('');
200
+ }
201
+ // inlineCard(インラインリンク)の場合
202
+ if (node.type === 'inlineCard' && node.attrs !== undefined) {
203
+ const url = node.attrs['url'];
204
+ if (typeof url === 'string') {
205
+ return `<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`;
206
+ }
207
+ return '';
208
+ }
209
+ // blockCard(ブロックリンク)の場合
210
+ if (node.type === 'blockCard' && node.attrs !== undefined) {
211
+ const url = node.attrs['url'];
212
+ if (typeof url === 'string') {
213
+ return `<p><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p>`;
214
+ }
215
+ return '';
216
+ }
217
+ // 子ノードがある場合
218
+ if (node.content !== undefined && Array.isArray(node.content)) {
219
+ const childrenHtml = node.content.map((child) => convertAdfNodeToHtml(child, attachmentPaths)).join('');
220
+ switch (node.type) {
221
+ case 'doc':
222
+ return childrenHtml;
223
+ case 'paragraph':
224
+ return `<p>${childrenHtml}</p>`;
225
+ case 'heading': {
226
+ const level = typeof node.attrs?.['level'] === 'number' ? node.attrs['level'] : 1;
227
+ const safeLevel = Math.max(1, Math.min(6, level));
228
+ return `<h${safeLevel}>${childrenHtml}</h${safeLevel}>`;
229
+ }
230
+ case 'bulletList':
231
+ return `<ul>${childrenHtml}</ul>`;
232
+ case 'orderedList':
233
+ return `<ol>${childrenHtml}</ol>`;
234
+ case 'listItem':
235
+ return `<li>${childrenHtml}</li>`;
236
+ case 'blockquote':
237
+ return `<blockquote>${childrenHtml}</blockquote>`;
238
+ case 'codeBlock': {
239
+ const language = typeof node.attrs?.['language'] === 'string' ? node.attrs['language'] : '';
240
+ const langClass = language ? ` class="language-${escapeHtml(language)}"` : '';
241
+ // コードブロック内のテキストは子ノードから取得
242
+ const codeText = node.content
243
+ .map((child) => (child.type === 'text' && child.text !== undefined ? child.text : ''))
244
+ .join('');
245
+ return `<pre><code${langClass}>${escapeHtml(codeText)}</code></pre>`;
246
+ }
247
+ case 'table':
248
+ return `<table>${childrenHtml}</table>`;
249
+ case 'tableRow':
250
+ return `<tr>${childrenHtml}</tr>`;
251
+ case 'tableHeader':
252
+ return `<th>${childrenHtml}</th>`;
253
+ case 'tableCell':
254
+ return `<td>${childrenHtml}</td>`;
255
+ case 'panel': {
256
+ // panel タイプを GitHub Alerts 形式に変換
257
+ const panelType = typeof node.attrs?.['panelType'] === 'string' ? node.attrs['panelType'] : 'info';
258
+ const alertTypeMap = {
259
+ error: 'WARNING',
260
+ info: 'NOTE',
261
+ note: 'NOTE',
262
+ success: 'TIP',
263
+ warning: 'WARNING',
264
+ };
265
+ const alertType = alertTypeMap[panelType] || 'NOTE';
266
+ return `<blockquote data-github-alert="${alertType}">${childrenHtml}</blockquote>`;
267
+ }
268
+ case 'expand':
269
+ case 'nestedExpand': {
270
+ const expandTitle = typeof node.attrs?.['title'] === 'string' ? node.attrs['title'] : '展開';
271
+ return `<details><summary>${escapeHtml(expandTitle)}</summary>${childrenHtml}</details>`;
272
+ }
273
+ // 未知のノードタイプは子ノードの内容を返す
274
+ default:
275
+ return childrenHtml;
276
+ }
277
+ }
278
+ // 子ノードもテキストもない場合は空文字列
279
+ return '';
280
+ };
281
+ /**
282
+ * ADF ドキュメントを HTML に変換する
283
+ *
284
+ * @param content ADF ドキュメントのコンテンツ配列
285
+ * @param attachmentPaths 添付ファイル ID → ローカルパスのマッピング
286
+ * @returns HTML 文字列
287
+ */
288
+ export const convertAdfContentToHtml = (content, attachmentPaths) => {
289
+ if (content === undefined) {
290
+ return '';
291
+ }
292
+ return content.map((node) => convertAdfNodeToHtml(node, attachmentPaths)).join('');
293
+ };
294
+ // ============================================================
295
+ // In-source Testing(プライベート関数のテスト)
296
+ // ============================================================
297
+ if (import.meta.vitest) {
298
+ const { describe, expect, it } = import.meta.vitest;
299
+ describe('escapeHtml', () => {
300
+ it('Given: 特殊文字を含む文字列, When: escapeHtml を呼び出す, Then: エスケープされる', () => {
301
+ expect(escapeHtml('&')).toBe('&amp;');
302
+ expect(escapeHtml('<')).toBe('&lt;');
303
+ expect(escapeHtml('>')).toBe('&gt;');
304
+ expect(escapeHtml('"')).toBe('&quot;');
305
+ expect(escapeHtml("'")).toBe('&#39;');
306
+ });
307
+ it('Given: 複数の特殊文字, When: escapeHtml を呼び出す, Then: すべてエスケープされる', () => {
308
+ expect(escapeHtml('<script>alert("XSS")</script>')).toBe('&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;');
309
+ });
310
+ });
311
+ describe('applyMarksToHtml', () => {
312
+ it('Given: strong マーク, When: applyMarksToHtml を呼び出す, Then: <strong> タグで囲まれる', () => {
313
+ const result = applyMarksToHtml('テキスト', [{ type: 'strong' }]);
314
+ expect(result).toBe('<strong>テキスト</strong>');
315
+ });
316
+ it('Given: em マーク, When: applyMarksToHtml を呼び出す, Then: <em> タグで囲まれる', () => {
317
+ const result = applyMarksToHtml('テキスト', [{ type: 'em' }]);
318
+ expect(result).toBe('<em>テキスト</em>');
319
+ });
320
+ it('Given: code マーク, When: applyMarksToHtml を呼び出す, Then: <code> タグで囲まれる', () => {
321
+ const result = applyMarksToHtml('code', [{ type: 'code' }]);
322
+ expect(result).toBe('<code>code</code>');
323
+ });
324
+ it('Given: strike マーク, When: applyMarksToHtml を呼び出す, Then: <s> タグで囲まれる', () => {
325
+ const result = applyMarksToHtml('テキスト', [{ type: 'strike' }]);
326
+ expect(result).toBe('<s>テキスト</s>');
327
+ });
328
+ it('Given: underline マーク, When: applyMarksToHtml を呼び出す, Then: <u> タグで囲まれる', () => {
329
+ const result = applyMarksToHtml('テキスト', [{ type: 'underline' }]);
330
+ expect(result).toBe('<u>テキスト</u>');
331
+ });
332
+ it('Given: link マーク, When: applyMarksToHtml を呼び出す, Then: <a> タグで囲まれる', () => {
333
+ const result = applyMarksToHtml('リンク', [{ attrs: { href: 'https://example.com' }, type: 'link' }]);
334
+ expect(result).toBe('<a href="https://example.com">リンク</a>');
335
+ });
336
+ it('Given: textColor マーク, When: applyMarksToHtml を呼び出す, Then: <span> タグで色が適用される', () => {
337
+ const result = applyMarksToHtml('テキスト', [{ attrs: { color: '#ff0000' }, type: 'textColor' }]);
338
+ expect(result).toBe('<span style="color: #ff0000">テキスト</span>');
339
+ });
340
+ it('Given: subsup マーク(sub), When: applyMarksToHtml を呼び出す, Then: <sub> タグで囲まれる', () => {
341
+ const result = applyMarksToHtml('2', [{ attrs: { type: 'sub' }, type: 'subsup' }]);
342
+ expect(result).toBe('<sub>2</sub>');
343
+ });
344
+ it('Given: subsup マーク(sup), When: applyMarksToHtml を呼び出す, Then: <sup> タグで囲まれる', () => {
345
+ const result = applyMarksToHtml('2', [{ attrs: { type: 'sup' }, type: 'subsup' }]);
346
+ expect(result).toBe('<sup>2</sup>');
347
+ });
348
+ it('Given: backgroundColor マーク, When: applyMarksToHtml を呼び出す, Then: 背景色が適用される', () => {
349
+ const result = applyMarksToHtml('テキスト', [{ attrs: { color: '#ffff00' }, type: 'backgroundColor' }]);
350
+ expect(result).toBe('<span style="background-color: #ffff00">テキスト</span>');
351
+ });
352
+ it('Given: 複数のマーク, When: applyMarksToHtml を呼び出す, Then: すべてのタグが適用される', () => {
353
+ const result = applyMarksToHtml('テキスト', [{ type: 'strong' }, { type: 'em' }]);
354
+ expect(result).toBe('<em><strong>テキスト</strong></em>');
355
+ });
356
+ it('Given: 未知のマーク, When: applyMarksToHtml を呼び出す, Then: 無視される', () => {
357
+ const result = applyMarksToHtml('テキスト', [{ type: 'unknown' }]);
358
+ expect(result).toBe('テキスト');
359
+ });
360
+ });
361
+ describe('convertAdfNodeToHtml', () => {
362
+ it('Given: テキストノード, When: convertAdfNodeToHtml を呼び出す, Then: エスケープされたテキストが返される', () => {
363
+ const node = { text: '<script>', type: 'text' };
364
+ expect(convertAdfNodeToHtml(node)).toBe('&lt;script&gt;');
365
+ });
366
+ it('Given: hardBreak, When: convertAdfNodeToHtml を呼び出す, Then: <br> が返される', () => {
367
+ const node = { type: 'hardBreak' };
368
+ expect(convertAdfNodeToHtml(node)).toBe('<br>');
369
+ });
370
+ it('Given: rule, When: convertAdfNodeToHtml を呼び出す, Then: <hr> が返される', () => {
371
+ const node = { type: 'rule' };
372
+ expect(convertAdfNodeToHtml(node)).toBe('<hr>');
373
+ });
374
+ it('Given: mention, When: convertAdfNodeToHtml を呼び出す, Then: メンションテキストが返される', () => {
375
+ const node = { attrs: { text: '@田中' }, type: 'mention' };
376
+ expect(convertAdfNodeToHtml(node)).toBe('@田中');
377
+ });
378
+ it('Given: emoji with text, When: convertAdfNodeToHtml を呼び出す, Then: 絵文字テキストが返される', () => {
379
+ const node = { attrs: { text: '😀' }, type: 'emoji' };
380
+ expect(convertAdfNodeToHtml(node)).toBe('😀');
381
+ });
382
+ it('Given: date, When: convertAdfNodeToHtml を呼び出す, Then: <time> タグが返される', () => {
383
+ const node = { attrs: { timestamp: 1609459200000 }, type: 'date' };
384
+ expect(convertAdfNodeToHtml(node)).toBe('<time datetime="2021-01-01">2021-01-01</time>');
385
+ });
386
+ it('Given: status, When: convertAdfNodeToHtml を呼び出す, Then: ステータスバッジが返される', () => {
387
+ const node = { attrs: { color: 'green', text: '完了' }, type: 'status' };
388
+ expect(convertAdfNodeToHtml(node)).toBe('[🟢 完了]');
389
+ });
390
+ it('Given: media with attachmentPaths, When: convertAdfNodeToHtml を呼び出す, Then: <img> タグが返される', () => {
391
+ const node = { attrs: { id: 'file-123' }, type: 'media' };
392
+ const paths = { 'file-123': '/attachments/image.png' };
393
+ expect(convertAdfNodeToHtml(node, paths)).toBe('<img src="/attachments/image.png" alt="file-123">');
394
+ });
395
+ it('Given: media without mapping, When: convertAdfNodeToHtml を呼び出す, Then: プレースホルダーが返される', () => {
396
+ const node = { attrs: { id: 'file-123' }, type: 'media' };
397
+ expect(convertAdfNodeToHtml(node)).toBe('[添付ファイル]');
398
+ });
399
+ it('Given: inlineCard, When: convertAdfNodeToHtml を呼び出す, Then: <a> タグが返される', () => {
400
+ const node = { attrs: { url: 'https://example.com' }, type: 'inlineCard' };
401
+ expect(convertAdfNodeToHtml(node)).toBe('<a href="https://example.com">https://example.com</a>');
402
+ });
403
+ it('Given: blockCard, When: convertAdfNodeToHtml を呼び出す, Then: <p><a> タグが返される', () => {
404
+ const node = { attrs: { url: 'https://example.com' }, type: 'blockCard' };
405
+ expect(convertAdfNodeToHtml(node)).toBe('<p><a href="https://example.com">https://example.com</a></p>');
406
+ });
407
+ it('Given: paragraph, When: convertAdfNodeToHtml を呼び出す, Then: <p> タグで囲まれる', () => {
408
+ const node = { content: [{ text: 'テスト', type: 'text' }], type: 'paragraph' };
409
+ expect(convertAdfNodeToHtml(node)).toBe('<p>テスト</p>');
410
+ });
411
+ it('Given: heading level 2, When: convertAdfNodeToHtml を呼び出す, Then: <h2> タグで囲まれる', () => {
412
+ const node = { attrs: { level: 2 }, content: [{ text: '見出し', type: 'text' }], type: 'heading' };
413
+ expect(convertAdfNodeToHtml(node)).toBe('<h2>見出し</h2>');
414
+ });
415
+ it('Given: bulletList, When: convertAdfNodeToHtml を呼び出す, Then: <ul> タグで囲まれる', () => {
416
+ const node = {
417
+ content: [
418
+ { content: [{ content: [{ text: 'アイテム', type: 'text' }], type: 'paragraph' }], type: 'listItem' },
419
+ ],
420
+ type: 'bulletList',
421
+ };
422
+ expect(convertAdfNodeToHtml(node)).toBe('<ul><li><p>アイテム</p></li></ul>');
423
+ });
424
+ it('Given: orderedList, When: convertAdfNodeToHtml を呼び出す, Then: <ol> タグで囲まれる', () => {
425
+ const node = {
426
+ content: [
427
+ { content: [{ content: [{ text: 'アイテム', type: 'text' }], type: 'paragraph' }], type: 'listItem' },
428
+ ],
429
+ type: 'orderedList',
430
+ };
431
+ expect(convertAdfNodeToHtml(node)).toBe('<ol><li><p>アイテム</p></li></ol>');
432
+ });
433
+ it('Given: blockquote, When: convertAdfNodeToHtml を呼び出す, Then: <blockquote> タグで囲まれる', () => {
434
+ const node = { content: [{ content: [{ text: '引用', type: 'text' }], type: 'paragraph' }], type: 'blockquote' };
435
+ expect(convertAdfNodeToHtml(node)).toBe('<blockquote><p>引用</p></blockquote>');
436
+ });
437
+ it('Given: codeBlock, When: convertAdfNodeToHtml を呼び出す, Then: <pre><code> タグで囲まれる', () => {
438
+ const node = {
439
+ attrs: { language: 'typescript' },
440
+ content: [{ text: 'const x = 1;', type: 'text' }],
441
+ type: 'codeBlock',
442
+ };
443
+ expect(convertAdfNodeToHtml(node)).toBe('<pre><code class="language-typescript">const x = 1;</code></pre>');
444
+ });
445
+ it('Given: table, When: convertAdfNodeToHtml を呼び出す, Then: <table> 構造が返される', () => {
446
+ const node = {
447
+ content: [
448
+ {
449
+ content: [
450
+ { content: [{ content: [{ text: 'ヘッダー', type: 'text' }], type: 'paragraph' }], type: 'tableHeader' },
451
+ ],
452
+ type: 'tableRow',
453
+ },
454
+ ],
455
+ type: 'table',
456
+ };
457
+ expect(convertAdfNodeToHtml(node)).toBe('<table><tr><th><p>ヘッダー</p></th></tr></table>');
458
+ });
459
+ it('Given: panel, When: convertAdfNodeToHtml を呼び出す, Then: GitHub Alerts 形式の blockquote が返される', () => {
460
+ const node = {
461
+ attrs: { panelType: 'info' },
462
+ content: [{ content: [{ text: '情報', type: 'text' }], type: 'paragraph' }],
463
+ type: 'panel',
464
+ };
465
+ expect(convertAdfNodeToHtml(node)).toBe('<blockquote data-github-alert="NOTE"><p>情報</p></blockquote>');
466
+ });
467
+ it('Given: expand, When: convertAdfNodeToHtml を呼び出す, Then: <details> タグが返される', () => {
468
+ const node = {
469
+ attrs: { title: '詳細' },
470
+ content: [{ content: [{ text: '内容', type: 'text' }], type: 'paragraph' }],
471
+ type: 'expand',
472
+ };
473
+ expect(convertAdfNodeToHtml(node)).toBe('<details><summary>詳細</summary><p>内容</p></details>');
474
+ });
475
+ });
476
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * ADF(Atlassian Document Format)→ PlainText 変換
3
+ */
4
+ import type { AdfNode } from './types.js';
5
+ /**
6
+ * ADF ノードからプレーンテキストを抽出する
7
+ *
8
+ * @param node ADF ノード
9
+ * @returns 抽出されたプレーンテキスト
10
+ */
11
+ export declare const extractTextFromAdfNode: (node: AdfNode) => string;
12
+ /**
13
+ * ADF ドキュメントのトップレベルコンテンツを処理する
14
+ *
15
+ * @param content トップレベルのコンテンツ配列
16
+ * @returns プレーンテキスト
17
+ */
18
+ export declare const processAdfContent: (content: readonly AdfNode[]) => string;
19
+ /**
20
+ * ADF(Atlassian Document Format)をプレーンテキストに変換する
21
+ *
22
+ * @param adf ADF ドキュメント(オブジェクトまたは JSON 文字列)
23
+ * @returns プレーンテキスト
24
+ */
25
+ export declare const convertAdfToPlainText: (adf: unknown) => string;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * ADF(Atlassian Document Format)→ PlainText 変換
3
+ */
4
+ import { isAdfDocument } from './types.js';
5
+ /**
6
+ * ADF ノードからプレーンテキストを抽出する
7
+ *
8
+ * @param node ADF ノード
9
+ * @returns 抽出されたプレーンテキスト
10
+ */
11
+ export const extractTextFromAdfNode = (node) => {
12
+ // テキストノードの場合
13
+ if (node.type === 'text' && node.text !== undefined) {
14
+ return node.text;
15
+ }
16
+ // 硬い改行の場合
17
+ if (node.type === 'hardBreak') {
18
+ return '\n';
19
+ }
20
+ // メンションの場合
21
+ if (node.type === 'mention' && node.attrs !== undefined) {
22
+ const text = node.attrs['text'];
23
+ if (typeof text === 'string') {
24
+ return text;
25
+ }
26
+ return '@ユーザー';
27
+ }
28
+ // 絵文字の場合
29
+ if (node.type === 'emoji' && node.attrs !== undefined) {
30
+ const text = node.attrs['text'];
31
+ const shortName = node.attrs['shortName'];
32
+ if (typeof text === 'string') {
33
+ return text;
34
+ }
35
+ if (typeof shortName === 'string') {
36
+ return shortName;
37
+ }
38
+ return '';
39
+ }
40
+ // メディアの場合
41
+ if (node.type === 'media') {
42
+ return '[添付ファイル]';
43
+ }
44
+ // mediaSingle の場合(メディアコンテナ)
45
+ if (node.type === 'mediaSingle' && node.content !== undefined) {
46
+ return node.content.map(extractTextFromAdfNode).join('');
47
+ }
48
+ // 子ノードがある場合は再帰的に処理
49
+ if (node.content !== undefined && Array.isArray(node.content)) {
50
+ const texts = node.content.map(extractTextFromAdfNode);
51
+ // パラグラフや見出しの後には改行を追加
52
+ if (node.type === 'paragraph' || node.type === 'heading') {
53
+ return texts.join('');
54
+ }
55
+ // リストアイテムの後には改行を追加
56
+ if (node.type === 'listItem') {
57
+ return `${texts.join('')}\n`;
58
+ }
59
+ // テーブルセルとヘッダーはタブで区切る
60
+ if (node.type === 'tableCell' || node.type === 'tableHeader') {
61
+ return `${texts.join('')}\t`;
62
+ }
63
+ // テーブル行は改行で区切る
64
+ if (node.type === 'tableRow') {
65
+ return `${texts.join('').trimEnd()}\n`;
66
+ }
67
+ return texts.join('');
68
+ }
69
+ return '';
70
+ };
71
+ /**
72
+ * ADF ドキュメントのトップレベルコンテンツを処理する
73
+ *
74
+ * @param content トップレベルのコンテンツ配列
75
+ * @returns プレーンテキスト
76
+ */
77
+ export const processAdfContent = (content) => {
78
+ const results = [];
79
+ for (const node of content) {
80
+ const text = extractTextFromAdfNode(node);
81
+ if (text !== '') {
82
+ results.push(text);
83
+ }
84
+ }
85
+ // パラグラフや見出しは改行で結合
86
+ return results.join('\n');
87
+ };
88
+ /**
89
+ * ADF(Atlassian Document Format)をプレーンテキストに変換する
90
+ *
91
+ * @param adf ADF ドキュメント(オブジェクトまたは JSON 文字列)
92
+ * @returns プレーンテキスト
93
+ */
94
+ export const convertAdfToPlainText = (adf) => {
95
+ // null または undefined の場合は空文字列を返す
96
+ if (adf === null || adf === undefined) {
97
+ return '';
98
+ }
99
+ // 文字列の場合は JSON としてパースを試みる
100
+ if (typeof adf === 'string') {
101
+ try {
102
+ const parsed = JSON.parse(adf);
103
+ if (isAdfDocument(parsed)) {
104
+ return processAdfContent(parsed.content);
105
+ }
106
+ }
107
+ catch {
108
+ // JSON パースに失敗した場合は元の文字列を返す
109
+ return adf;
110
+ }
111
+ // パースできたが ADF 形式でない場合は元の文字列を返す
112
+ return adf;
113
+ }
114
+ // オブジェクトの場合は ADF ドキュメントとして処理
115
+ if (isAdfDocument(adf)) {
116
+ return processAdfContent(adf.content);
117
+ }
118
+ return '';
119
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Markdown 変換ユーティリティ
3
+ *
4
+ * TurndownService の設定と前処理を提供する
5
+ */
6
+ import TurndownService from 'turndown';
7
+ import type { AttachmentPathMapping } from './types.js';
8
+ /**
9
+ * 前処理: 無視する要素を削除し、Confluence 固有タグを処理
10
+ *
11
+ * @param html HTML 文字列
12
+ * @param attachmentPaths 添付ファイルマッピング
13
+ * @returns 前処理済み HTML
14
+ */
15
+ export declare const preprocessHtmlForMarkdown: (html: string, attachmentPaths?: AttachmentPathMapping) => string;
16
+ /**
17
+ * TurndownService インスタンスを作成する(共通設定)
18
+ * Jira ADF と Confluence Storage Format の両方で使用する
19
+ *
20
+ * @returns 設定済みの TurndownService インスタンス
21
+ */
22
+ export declare const createTurndownService: () => TurndownService;