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.
- package/README.md +20 -7
- package/dist/services/fetch/fetch-service.js +4 -5
- package/dist/services/jira/jira-service.js +2 -0
- package/dist/services/storage/storage-service.d.ts +5 -3
- package/dist/services/storage/storage-service.js +297 -36
- package/dist/services/text-converter/adf-to-html.d.ts +37 -0
- package/dist/services/text-converter/adf-to-html.js +476 -0
- package/dist/services/text-converter/adf-to-plain-text.d.ts +25 -0
- package/dist/services/text-converter/adf-to-plain-text.js +119 -0
- package/dist/services/text-converter/markdown-utils.d.ts +22 -0
- package/dist/services/text-converter/markdown-utils.js +270 -0
- package/dist/services/text-converter/storage-to-plain-text.d.ts +66 -0
- package/dist/services/text-converter/storage-to-plain-text.js +238 -0
- package/dist/services/text-converter/text-converter.d.ts +8 -18
- package/dist/services/text-converter/text-converter.js +23 -630
- package/dist/services/text-converter/types.d.ts +40 -0
- package/dist/services/text-converter/types.js +16 -0
- package/dist/types/jira.d.ts +5 -1
- package/dist/types/result.d.ts +104 -0
- package/dist/types/result.js +119 -0
- package/dist/types/storage.d.ts +3 -3
- package/package.json +1 -1
|
@@ -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, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, ''');
|
|
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('&');
|
|
302
|
+
expect(escapeHtml('<')).toBe('<');
|
|
303
|
+
expect(escapeHtml('>')).toBe('>');
|
|
304
|
+
expect(escapeHtml('"')).toBe('"');
|
|
305
|
+
expect(escapeHtml("'")).toBe(''');
|
|
306
|
+
});
|
|
307
|
+
it('Given: 複数の特殊文字, When: escapeHtml を呼び出す, Then: すべてエスケープされる', () => {
|
|
308
|
+
expect(escapeHtml('<script>alert("XSS")</script>')).toBe('<script>alert("XSS")</script>');
|
|
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('<script>');
|
|
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;
|