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
|
@@ -2,124 +2,38 @@
|
|
|
2
2
|
* テキスト変換サービス
|
|
3
3
|
*
|
|
4
4
|
* Jira の ADF(Atlassian Document Format)と Confluence の Storage Format を
|
|
5
|
-
*
|
|
6
|
-
* また、Confluence の Storage Format を Markdown に変換する機能も提供する。
|
|
5
|
+
* プレーンテキストや Markdown に変換する機能を提供する。
|
|
7
6
|
*/
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
7
|
+
import { convertAdfContentToHtml } from './adf-to-html.js';
|
|
8
|
+
import { createTurndownService, preprocessHtmlForMarkdown } from './markdown-utils.js';
|
|
9
|
+
import { isAdfDocument } from './types.js';
|
|
10
|
+
// 公開 API の再エクスポート
|
|
11
|
+
export { convertAdfToPlainText } from './adf-to-plain-text.js';
|
|
12
|
+
export { convertStorageFormatToPlainText } from './storage-to-plain-text.js';
|
|
10
13
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* @param input 入力値
|
|
14
|
-
* @returns ADF ドキュメント形式の場合 true
|
|
15
|
-
*/
|
|
16
|
-
const isAdfDocument = (input) => {
|
|
17
|
-
if (typeof input !== 'object' || input === null) {
|
|
18
|
-
return false;
|
|
19
|
-
}
|
|
20
|
-
const doc = input;
|
|
21
|
-
return doc['type'] === 'doc' && Array.isArray(doc['content']);
|
|
22
|
-
};
|
|
23
|
-
/**
|
|
24
|
-
* ADF ノードからプレーンテキストを抽出する
|
|
25
|
-
*
|
|
26
|
-
* @param node ADF ノード
|
|
27
|
-
* @returns 抽出されたプレーンテキスト
|
|
28
|
-
*/
|
|
29
|
-
const extractTextFromAdfNode = (node) => {
|
|
30
|
-
// テキストノードの場合
|
|
31
|
-
if (node.type === 'text' && node.text !== undefined) {
|
|
32
|
-
return node.text;
|
|
33
|
-
}
|
|
34
|
-
// 硬い改行の場合
|
|
35
|
-
if (node.type === 'hardBreak') {
|
|
36
|
-
return '\n';
|
|
37
|
-
}
|
|
38
|
-
// メンションの場合
|
|
39
|
-
if (node.type === 'mention' && node.attrs !== undefined) {
|
|
40
|
-
const text = node.attrs['text'];
|
|
41
|
-
if (typeof text === 'string') {
|
|
42
|
-
return text;
|
|
43
|
-
}
|
|
44
|
-
return '@ユーザー';
|
|
45
|
-
}
|
|
46
|
-
// 絵文字の場合
|
|
47
|
-
if (node.type === 'emoji' && node.attrs !== undefined) {
|
|
48
|
-
const text = node.attrs['text'];
|
|
49
|
-
const shortName = node.attrs['shortName'];
|
|
50
|
-
if (typeof text === 'string') {
|
|
51
|
-
return text;
|
|
52
|
-
}
|
|
53
|
-
if (typeof shortName === 'string') {
|
|
54
|
-
return shortName;
|
|
55
|
-
}
|
|
56
|
-
return '';
|
|
57
|
-
}
|
|
58
|
-
// メディアの場合
|
|
59
|
-
if (node.type === 'media') {
|
|
60
|
-
return '[添付ファイル]';
|
|
61
|
-
}
|
|
62
|
-
// mediaSingle の場合(メディアコンテナ)
|
|
63
|
-
if (node.type === 'mediaSingle' && node.content !== undefined) {
|
|
64
|
-
return node.content.map(extractTextFromAdfNode).join('');
|
|
65
|
-
}
|
|
66
|
-
// 子ノードがある場合は再帰的に処理
|
|
67
|
-
if (node.content !== undefined && Array.isArray(node.content)) {
|
|
68
|
-
const texts = node.content.map(extractTextFromAdfNode);
|
|
69
|
-
// パラグラフや見出しの後には改行を追加
|
|
70
|
-
if (node.type === 'paragraph' || node.type === 'heading') {
|
|
71
|
-
return texts.join('');
|
|
72
|
-
}
|
|
73
|
-
// リストアイテムの後には改行を追加
|
|
74
|
-
if (node.type === 'listItem') {
|
|
75
|
-
return `${texts.join('')}\n`;
|
|
76
|
-
}
|
|
77
|
-
// テーブルセルとヘッダーはタブで区切る
|
|
78
|
-
if (node.type === 'tableCell' || node.type === 'tableHeader') {
|
|
79
|
-
return `${texts.join('')}\t`;
|
|
80
|
-
}
|
|
81
|
-
// テーブル行は改行で区切る
|
|
82
|
-
if (node.type === 'tableRow') {
|
|
83
|
-
return `${texts.join('').trimEnd()}\n`;
|
|
84
|
-
}
|
|
85
|
-
return texts.join('');
|
|
86
|
-
}
|
|
87
|
-
return '';
|
|
88
|
-
};
|
|
89
|
-
/**
|
|
90
|
-
* ADF ドキュメントのトップレベルコンテンツを処理する
|
|
91
|
-
*
|
|
92
|
-
* @param content トップレベルのコンテンツ配列
|
|
93
|
-
* @returns プレーンテキスト
|
|
94
|
-
*/
|
|
95
|
-
const processAdfContent = (content) => {
|
|
96
|
-
const results = [];
|
|
97
|
-
for (const node of content) {
|
|
98
|
-
const text = extractTextFromAdfNode(node);
|
|
99
|
-
if (text !== '') {
|
|
100
|
-
results.push(text);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
// パラグラフや見出しは改行で結合
|
|
104
|
-
return results.join('\n');
|
|
105
|
-
};
|
|
106
|
-
/**
|
|
107
|
-
* ADF(Atlassian Document Format)をプレーンテキストに変換する
|
|
14
|
+
* ADF(Atlassian Document Format)を Markdown に変換する
|
|
108
15
|
*
|
|
109
16
|
* @param adf ADF ドキュメント(オブジェクトまたは JSON 文字列)
|
|
110
|
-
* @
|
|
17
|
+
* @param attachmentPaths 添付ファイル ID → ローカルパスのマッピング
|
|
18
|
+
* @returns Markdown 文字列
|
|
111
19
|
*/
|
|
112
|
-
export const
|
|
20
|
+
export const convertAdfToMarkdown = (adf, attachmentPaths) => {
|
|
113
21
|
// null または undefined の場合は空文字列を返す
|
|
114
22
|
if (adf === null || adf === undefined) {
|
|
115
23
|
return '';
|
|
116
24
|
}
|
|
117
25
|
// 文字列の場合は JSON としてパースを試みる
|
|
118
26
|
if (typeof adf === 'string') {
|
|
27
|
+
// 空文字列の場合
|
|
28
|
+
if (adf === '') {
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
119
31
|
try {
|
|
120
32
|
const parsed = JSON.parse(adf);
|
|
121
33
|
if (isAdfDocument(parsed)) {
|
|
122
|
-
|
|
34
|
+
const html = convertAdfContentToHtml(parsed.content, attachmentPaths);
|
|
35
|
+
const turndownService = createTurndownService();
|
|
36
|
+
return turndownService.turndown(html).trim();
|
|
123
37
|
}
|
|
124
38
|
}
|
|
125
39
|
catch {
|
|
@@ -131,222 +45,12 @@ export const convertAdfToPlainText = (adf) => {
|
|
|
131
45
|
}
|
|
132
46
|
// オブジェクトの場合は ADF ドキュメントとして処理
|
|
133
47
|
if (isAdfDocument(adf)) {
|
|
134
|
-
|
|
48
|
+
const html = convertAdfContentToHtml(adf.content, attachmentPaths);
|
|
49
|
+
const turndownService = createTurndownService();
|
|
50
|
+
return turndownService.turndown(html).trim();
|
|
135
51
|
}
|
|
136
52
|
return '';
|
|
137
53
|
};
|
|
138
|
-
/**
|
|
139
|
-
* HTML エンティティをデコードする
|
|
140
|
-
*
|
|
141
|
-
* @param text エンコードされた文字列
|
|
142
|
-
* @returns デコードされた文字列
|
|
143
|
-
*/
|
|
144
|
-
const decodeHtmlEntities = (text) => {
|
|
145
|
-
return text
|
|
146
|
-
.replace(/ /g, ' ')
|
|
147
|
-
.replace(/&/g, '&')
|
|
148
|
-
.replace(/</g, '<')
|
|
149
|
-
.replace(/>/g, '>')
|
|
150
|
-
.replace(/"/g, '"')
|
|
151
|
-
.replace(/'/g, "'")
|
|
152
|
-
.replace(/'/g, "'")
|
|
153
|
-
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number.parseInt(code, 10)));
|
|
154
|
-
};
|
|
155
|
-
/**
|
|
156
|
-
* CDATA セクションからテキストを抽出する
|
|
157
|
-
*
|
|
158
|
-
* @param html HTML 文字列
|
|
159
|
-
* @returns CDATA セクションを処理した文字列
|
|
160
|
-
*/
|
|
161
|
-
const extractCdata = (html) => {
|
|
162
|
-
return html.replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1');
|
|
163
|
-
};
|
|
164
|
-
/**
|
|
165
|
-
* Confluence マクロのパラメータからテキストを抽出する
|
|
166
|
-
*
|
|
167
|
-
* @param html HTML 文字列
|
|
168
|
-
* @returns 処理された文字列
|
|
169
|
-
*/
|
|
170
|
-
const extractMacroParameters = (html) => {
|
|
171
|
-
// ac:parameter タグから title などのテキストを抽出
|
|
172
|
-
return html.replace(/<ac:parameter[^>]*ac:name="title"[^>]*>([^<]*)<\/ac:parameter>/g, '$1');
|
|
173
|
-
};
|
|
174
|
-
/**
|
|
175
|
-
* 画像タグをプレースホルダーに変換する
|
|
176
|
-
*
|
|
177
|
-
* @param html HTML 文字列
|
|
178
|
-
* @returns 処理された文字列
|
|
179
|
-
*/
|
|
180
|
-
const convertImagesToPlaceholders = (html) => {
|
|
181
|
-
// ac:image タグと ri:attachment から画像ファイル名を抽出
|
|
182
|
-
return html.replace(/<ac:image[^>]*>[\s\S]*?<ri:attachment\s+ri:filename="([^"]*)"[^>]*\/>[\s\S]*?<\/ac:image>/g, '[画像: $1]');
|
|
183
|
-
};
|
|
184
|
-
/**
|
|
185
|
-
* ユーザーリンクをプレースホルダーに変換する
|
|
186
|
-
*
|
|
187
|
-
* @param html HTML 文字列
|
|
188
|
-
* @returns 処理された文字列
|
|
189
|
-
*/
|
|
190
|
-
const convertUserLinksToPlaceholders = (html) => {
|
|
191
|
-
// ri:user タグをプレースホルダーに変換
|
|
192
|
-
return html.replace(/<ac:link[^>]*>[\s\S]*?<ri:user[^>]*\/>[\s\S]*?<\/ac:link>/g, '[ユーザー]');
|
|
193
|
-
};
|
|
194
|
-
/**
|
|
195
|
-
* ブロック要素のタグを処理して改行を適切に挿入する
|
|
196
|
-
*
|
|
197
|
-
* @param html HTML 文字列
|
|
198
|
-
* @returns 処理された文字列
|
|
199
|
-
*/
|
|
200
|
-
const processBlockElements = (html) => {
|
|
201
|
-
// 閉じタグの前後に改行マーカーを追加
|
|
202
|
-
let result = html;
|
|
203
|
-
// パラグラフと見出しの後に改行
|
|
204
|
-
result = result.replace(/<\/(p|h[1-6])>/gi, '</$1>\n');
|
|
205
|
-
// リストアイテムの後に改行
|
|
206
|
-
result = result.replace(/<\/li>/gi, '</li>\n');
|
|
207
|
-
// テーブル行の後に改行
|
|
208
|
-
result = result.replace(/<\/tr>/gi, '</tr>\n');
|
|
209
|
-
// テーブルセルの後にタブ
|
|
210
|
-
result = result.replace(/<\/(td|th)>/gi, '\t</$1>');
|
|
211
|
-
// br タグを改行に変換
|
|
212
|
-
result = result.replace(/<br\s*\/?>/gi, '\n');
|
|
213
|
-
// blockquote の後に改行
|
|
214
|
-
result = result.replace(/<\/blockquote>/gi, '</blockquote>\n');
|
|
215
|
-
return result;
|
|
216
|
-
};
|
|
217
|
-
/**
|
|
218
|
-
* HTML タグを除去する
|
|
219
|
-
*
|
|
220
|
-
* @param html HTML 文字列
|
|
221
|
-
* @returns タグを除去した文字列
|
|
222
|
-
*/
|
|
223
|
-
const stripHtmlTags = (html) => {
|
|
224
|
-
return html.replace(/<[^>]*>/g, '');
|
|
225
|
-
};
|
|
226
|
-
/**
|
|
227
|
-
* 連続する空白を正規化する
|
|
228
|
-
*
|
|
229
|
-
* @param text テキスト
|
|
230
|
-
* @returns 正規化されたテキスト
|
|
231
|
-
*/
|
|
232
|
-
const normalizeWhitespace = (text) => {
|
|
233
|
-
// 行ごとに処理
|
|
234
|
-
const lines = text.split('\n');
|
|
235
|
-
const normalizedLines = lines.map((line) => {
|
|
236
|
-
// 行内の連続する空白を単一スペースに
|
|
237
|
-
return line.replace(/[ \t]+/g, ' ').trim();
|
|
238
|
-
});
|
|
239
|
-
// 空行を除去して結合
|
|
240
|
-
return normalizedLines.filter((line) => line !== '').join('\n');
|
|
241
|
-
};
|
|
242
|
-
/**
|
|
243
|
-
* Confluence の Storage Format(XHTML)をプレーンテキストに変換する
|
|
244
|
-
*
|
|
245
|
-
* @param storageFormat Storage Format 文字列
|
|
246
|
-
* @returns プレーンテキスト
|
|
247
|
-
*/
|
|
248
|
-
export const convertStorageFormatToPlainText = (storageFormat) => {
|
|
249
|
-
// null または undefined の場合は空文字列を返す
|
|
250
|
-
if (storageFormat === null || storageFormat === undefined || storageFormat === '') {
|
|
251
|
-
return '';
|
|
252
|
-
}
|
|
253
|
-
let result = storageFormat;
|
|
254
|
-
// CDATA セクションを処理
|
|
255
|
-
result = extractCdata(result);
|
|
256
|
-
// マクロパラメータからテキストを抽出
|
|
257
|
-
result = extractMacroParameters(result);
|
|
258
|
-
// 画像をプレースホルダーに変換
|
|
259
|
-
result = convertImagesToPlaceholders(result);
|
|
260
|
-
// ユーザーリンクをプレースホルダーに変換
|
|
261
|
-
result = convertUserLinksToPlaceholders(result);
|
|
262
|
-
// ブロック要素を処理
|
|
263
|
-
result = processBlockElements(result);
|
|
264
|
-
// HTML タグを除去
|
|
265
|
-
result = stripHtmlTags(result);
|
|
266
|
-
// HTML エンティティをデコード
|
|
267
|
-
result = decodeHtmlEntities(result);
|
|
268
|
-
// 空白を正規化
|
|
269
|
-
result = normalizeWhitespace(result);
|
|
270
|
-
return result;
|
|
271
|
-
};
|
|
272
|
-
/**
|
|
273
|
-
* テーブルが Markdown に変換可能か判定する
|
|
274
|
-
* - セル結合(colspan/rowspan)がないこと
|
|
275
|
-
* - セル内改行(<br>)がないこと
|
|
276
|
-
*
|
|
277
|
-
* @param tableHtml テーブルの HTML 文字列
|
|
278
|
-
* @returns 変換可能な場合 true
|
|
279
|
-
*/
|
|
280
|
-
const isTableConvertibleToMarkdown = (tableHtml) => {
|
|
281
|
-
// colspan/rowspan 属性チェック
|
|
282
|
-
const hasCellMerge = /\b(colspan|rowspan)\s*=/i.test(tableHtml);
|
|
283
|
-
// セル内 <br> チェック(<td> または <th> 内の <br>)
|
|
284
|
-
const hasCellBreak = /<t[dh][^>]*>[\s\S]*?<br[\s/]*>[\s\S]*?<\/t[dh]>/i.test(tableHtml);
|
|
285
|
-
return !hasCellMerge && !hasCellBreak;
|
|
286
|
-
};
|
|
287
|
-
/**
|
|
288
|
-
* 前処理: 無視する要素を削除し、Confluence 固有タグを処理
|
|
289
|
-
*
|
|
290
|
-
* @param html HTML 文字列
|
|
291
|
-
* @param attachmentPaths 添付ファイルマッピング
|
|
292
|
-
* @returns 前処理済み HTML
|
|
293
|
-
*/
|
|
294
|
-
const preprocessHtmlForMarkdown = (html, attachmentPaths) => {
|
|
295
|
-
let result = html;
|
|
296
|
-
// colgroup/col を削除
|
|
297
|
-
result = result.replace(/<colgroup[\s\S]*?<\/colgroup>/gi, '');
|
|
298
|
-
result = result.replace(/<col[^>]*\/?>/gi, '');
|
|
299
|
-
// data-highlight-colour 属性を削除
|
|
300
|
-
result = result.replace(/\s*data-highlight-colour="[^"]*"/gi, '');
|
|
301
|
-
// ac:local-id, local-id 属性を削除
|
|
302
|
-
result = result.replace(/\s*(ac:)?local-id="[^"]*"/gi, '');
|
|
303
|
-
// ac:inline-comment-marker を内容のみに置換
|
|
304
|
-
result = result.replace(/<ac:inline-comment-marker[^>]*>([\s\S]*?)<\/ac:inline-comment-marker>/gi, '$1');
|
|
305
|
-
// CDATA セクションを処理
|
|
306
|
-
result = result.replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1');
|
|
307
|
-
// --------------------------------------------------
|
|
308
|
-
// Confluence 固有タグを標準 HTML タグに変換(turndown が認識できる形式へ)
|
|
309
|
-
// --------------------------------------------------
|
|
310
|
-
// ac:image + ac:caption を <img> + <figcaption> に変換(キャプション付きを先に処理)
|
|
311
|
-
result = result.replace(/<ac:image[^>]*>[\s\S]*?<ri:attachment[^>]*ri:filename="([^"]*)"[^>]*\/?>[\s\S]*?<ac:caption>([^<]*)<\/ac:caption>[\s\S]*?<\/ac:image>/gi, (_match, filename, caption) => {
|
|
312
|
-
const localPath = attachmentPaths?.[filename] || filename;
|
|
313
|
-
return `<figure><img src="${localPath}" alt="${filename}"><figcaption>${caption}</figcaption></figure>`;
|
|
314
|
-
});
|
|
315
|
-
// ac:image を <img> に変換(キャプションなしの残り)
|
|
316
|
-
result = result.replace(/<ac:image[^>]*>[\s\S]*?<ri:attachment[^>]*ri:filename="([^"]*)"[^>]*\/?>[\s\S]*?<\/ac:image>/gi, (_match, filename) => {
|
|
317
|
-
const localPath = attachmentPaths?.[filename] || filename;
|
|
318
|
-
return `<img src="${localPath}" alt="${filename}">`;
|
|
319
|
-
});
|
|
320
|
-
// ac:structured-macro name="code" を <pre><code> に変換
|
|
321
|
-
result = result.replace(/<ac:structured-macro[^>]*ac:name="code"[^>]*>([\s\S]*?)<\/ac:structured-macro>/gi, (_match, innerContent) => {
|
|
322
|
-
// language パラメータを抽出
|
|
323
|
-
const langMatch = innerContent.match(/<ac:parameter[^>]*ac:name="language"[^>]*>([^<]*)<\/ac:parameter>/i);
|
|
324
|
-
const lang = langMatch?.[1] || '';
|
|
325
|
-
// plain-text-body の内容を抽出
|
|
326
|
-
const bodyMatch = innerContent.match(/<ac:plain-text-body[^>]*>([\s\S]*?)<\/ac:plain-text-body>/i);
|
|
327
|
-
const code = bodyMatch?.[1] || '';
|
|
328
|
-
// turndown が認識できる形式に変換
|
|
329
|
-
const langClass = lang ? ` class="language-${lang}"` : '';
|
|
330
|
-
return `<pre><code${langClass}>${code}</code></pre>`;
|
|
331
|
-
});
|
|
332
|
-
// ac:structured-macro name="info/note/tip/warning" を GitHub Alerts 形式の blockquote に変換
|
|
333
|
-
const alertMacros = ['info', 'note', 'tip', 'warning'];
|
|
334
|
-
const alertTypeMap = {
|
|
335
|
-
info: 'NOTE',
|
|
336
|
-
note: 'NOTE',
|
|
337
|
-
tip: 'TIP',
|
|
338
|
-
warning: 'WARNING',
|
|
339
|
-
};
|
|
340
|
-
for (const macroName of alertMacros) {
|
|
341
|
-
const pattern = new RegExp(`<ac:structured-macro[^>]*ac:name="${macroName}"[^>]*>[\\s\\S]*?<ac:rich-text-body>([\\s\\S]*?)<\\/ac:rich-text-body>[\\s\\S]*?<\\/ac:structured-macro>`, 'gi');
|
|
342
|
-
result = result.replace(pattern, (_match, content) => {
|
|
343
|
-
const alertType = alertTypeMap[macroName] || 'NOTE';
|
|
344
|
-
// 専用のマーカー属性を持つ blockquote に変換
|
|
345
|
-
return `<blockquote data-github-alert="${alertType}">${content}</blockquote>`;
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
return result;
|
|
349
|
-
};
|
|
350
54
|
/**
|
|
351
55
|
* Confluence Storage Format(XHTML)を Markdown に変換する
|
|
352
56
|
*
|
|
@@ -361,321 +65,10 @@ export const convertStorageFormatToMarkdown = (storageFormat, attachmentPaths) =
|
|
|
361
65
|
}
|
|
362
66
|
// 前処理
|
|
363
67
|
const preprocessedHtml = preprocessHtmlForMarkdown(storageFormat, attachmentPaths);
|
|
364
|
-
//
|
|
365
|
-
const turndownService =
|
|
366
|
-
bulletListMarker: '-',
|
|
367
|
-
codeBlockStyle: 'fenced',
|
|
368
|
-
emDelimiter: '*',
|
|
369
|
-
headingStyle: 'atx',
|
|
370
|
-
strongDelimiter: '**',
|
|
371
|
-
});
|
|
372
|
-
// GFM プラグイン(テーブル、取り消し線など)を使用
|
|
373
|
-
turndownService.use(gfm);
|
|
374
|
-
// --------------------------------------------------
|
|
375
|
-
// カスタムルール: キャプション付き画像(<figure>)
|
|
376
|
-
// --------------------------------------------------
|
|
377
|
-
turndownService.addRule('figureWithCaption', {
|
|
378
|
-
filter: (node) => {
|
|
379
|
-
return node.nodeName === 'FIGURE';
|
|
380
|
-
},
|
|
381
|
-
replacement: (_content, node) => {
|
|
382
|
-
const element = node;
|
|
383
|
-
const img = element.querySelector('img');
|
|
384
|
-
const figcaption = element.querySelector('figcaption');
|
|
385
|
-
if (img) {
|
|
386
|
-
const src = img.getAttribute('src') || '';
|
|
387
|
-
const alt = img.getAttribute('alt') || '';
|
|
388
|
-
let result = ``;
|
|
389
|
-
if (figcaption) {
|
|
390
|
-
const captionText = figcaption.textContent?.trim() || '';
|
|
391
|
-
if (captionText) {
|
|
392
|
-
result += `\n\n*${captionText}*`;
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
return result;
|
|
396
|
-
}
|
|
397
|
-
return '';
|
|
398
|
-
},
|
|
399
|
-
});
|
|
400
|
-
// --------------------------------------------------
|
|
401
|
-
// カスタムルール: GitHub Alerts(<blockquote data-github-alert="...">)
|
|
402
|
-
// --------------------------------------------------
|
|
403
|
-
turndownService.addRule('githubAlerts', {
|
|
404
|
-
filter: (node) => {
|
|
405
|
-
if (node.nodeName !== 'BLOCKQUOTE')
|
|
406
|
-
return false;
|
|
407
|
-
return node.hasAttribute('data-github-alert');
|
|
408
|
-
},
|
|
409
|
-
replacement: (content, node) => {
|
|
410
|
-
const element = node;
|
|
411
|
-
const alertType = element.getAttribute('data-github-alert') || 'NOTE';
|
|
412
|
-
// 内容を再帰的に Markdown 変換
|
|
413
|
-
const innerMarkdown = content.trim();
|
|
414
|
-
// 各行に > プレフィックス付加
|
|
415
|
-
const lines = innerMarkdown.split('\n');
|
|
416
|
-
const quotedContent = lines.map((line) => `> ${line}`).join('\n');
|
|
417
|
-
return `\n> [!${alertType}]\n${quotedContent}\n`;
|
|
418
|
-
},
|
|
419
|
-
});
|
|
420
|
-
// --------------------------------------------------
|
|
421
|
-
// カスタムルール: 色変更テキスト(HTML のまま出力)
|
|
422
|
-
// --------------------------------------------------
|
|
423
|
-
turndownService.addRule('coloredText', {
|
|
424
|
-
filter: (node) => {
|
|
425
|
-
if (node.nodeName !== 'SPAN')
|
|
426
|
-
return false;
|
|
427
|
-
const style = node.getAttribute('style') || '';
|
|
428
|
-
return style.includes('color:') || style.includes('color :');
|
|
429
|
-
},
|
|
430
|
-
replacement: (_content, node) => {
|
|
431
|
-
// HTML のまま出力
|
|
432
|
-
return node.outerHTML;
|
|
433
|
-
},
|
|
434
|
-
});
|
|
435
|
-
// --------------------------------------------------
|
|
436
|
-
// カスタムルール: セル結合/セル内改行のあるテーブル(HTML のまま出力)
|
|
437
|
-
// --------------------------------------------------
|
|
438
|
-
turndownService.addRule('complexTable', {
|
|
439
|
-
filter: (node) => {
|
|
440
|
-
if (node.nodeName !== 'TABLE')
|
|
441
|
-
return false;
|
|
442
|
-
const tableHtml = node.outerHTML;
|
|
443
|
-
return !isTableConvertibleToMarkdown(tableHtml);
|
|
444
|
-
},
|
|
445
|
-
replacement: (_content, node) => {
|
|
446
|
-
// HTML のまま出力
|
|
447
|
-
return node.outerHTML;
|
|
448
|
-
},
|
|
449
|
-
});
|
|
68
|
+
// 共通の TurndownService を使用
|
|
69
|
+
const turndownService = createTurndownService();
|
|
450
70
|
// Markdown に変換
|
|
451
71
|
const markdown = turndownService.turndown(preprocessedHtml);
|
|
452
72
|
// 末尾の空白を除去
|
|
453
73
|
return markdown.trim();
|
|
454
74
|
};
|
|
455
|
-
// ============================================================
|
|
456
|
-
// In-source Testing(プライベート関数のテスト)
|
|
457
|
-
// ============================================================
|
|
458
|
-
if (import.meta.vitest) {
|
|
459
|
-
const { describe, expect, it } = import.meta.vitest;
|
|
460
|
-
describe('extractTextFromAdfNode (in-source testing)', () => {
|
|
461
|
-
// テストの目的: hardBreak が正確に '\n' を返すこと
|
|
462
|
-
describe('hardBreak の戻り値検証', () => {
|
|
463
|
-
it('Given: hardBreak ノード, When: extractTextFromAdfNode を呼び出す, Then: 厳密に "\\n" が返される', () => {
|
|
464
|
-
// Given: hardBreak ノード
|
|
465
|
-
const node = { type: 'hardBreak' };
|
|
466
|
-
// When: extractTextFromAdfNode を呼び出す
|
|
467
|
-
const result = extractTextFromAdfNode(node);
|
|
468
|
-
// Then: 厳密に '\n' が返される
|
|
469
|
-
expect(result).toBe('\n');
|
|
470
|
-
expect(result.length).toBe(1);
|
|
471
|
-
expect(result.charCodeAt(0)).toBe(10); // LF のコードポイント
|
|
472
|
-
});
|
|
473
|
-
});
|
|
474
|
-
// テストの目的: media ノードが '[添付ファイル]' を返すこと
|
|
475
|
-
describe('media の戻り値検証', () => {
|
|
476
|
-
it('Given: media ノード, When: extractTextFromAdfNode を呼び出す, Then: 厳密に "[添付ファイル]" が返される', () => {
|
|
477
|
-
// Given: media ノード
|
|
478
|
-
const node = { attrs: { id: 'test-123' }, type: 'media' };
|
|
479
|
-
// When: extractTextFromAdfNode を呼び出す
|
|
480
|
-
const result = extractTextFromAdfNode(node);
|
|
481
|
-
// Then: 厳密に '[添付ファイル]' が返される
|
|
482
|
-
expect(result).toBe('[添付ファイル]');
|
|
483
|
-
expect(result.length).toBe(8);
|
|
484
|
-
});
|
|
485
|
-
});
|
|
486
|
-
// テストの目的: mention で text がない場合 '@ユーザー' を返すこと
|
|
487
|
-
describe('mention のデフォルトプレースホルダー検証', () => {
|
|
488
|
-
it('Given: text のない mention, When: extractTextFromAdfNode を呼び出す, Then: 厳密に "@ユーザー" が返される', () => {
|
|
489
|
-
// Given: text のない mention
|
|
490
|
-
const node = { attrs: { id: 'user-123' }, type: 'mention' };
|
|
491
|
-
// When: extractTextFromAdfNode を呼び出す
|
|
492
|
-
const result = extractTextFromAdfNode(node);
|
|
493
|
-
// Then: 厳密に '@ユーザー' が返される
|
|
494
|
-
expect(result).toBe('@ユーザー');
|
|
495
|
-
expect(result.length).toBe(5);
|
|
496
|
-
});
|
|
497
|
-
});
|
|
498
|
-
// テストの目的: listItem が末尾に改行を追加すること
|
|
499
|
-
describe('listItem の末尾改行検証', () => {
|
|
500
|
-
it('Given: listItem ノード, When: extractTextFromAdfNode を呼び出す, Then: 末尾に改行が付く', () => {
|
|
501
|
-
// Given: listItem ノード
|
|
502
|
-
const node = {
|
|
503
|
-
content: [{ content: [{ text: 'アイテム', type: 'text' }], type: 'paragraph' }],
|
|
504
|
-
type: 'listItem',
|
|
505
|
-
};
|
|
506
|
-
// When: extractTextFromAdfNode を呼び出す
|
|
507
|
-
const result = extractTextFromAdfNode(node);
|
|
508
|
-
// Then: 末尾に改行が付く
|
|
509
|
-
expect(result).toBe('アイテム\n');
|
|
510
|
-
expect(result.endsWith('\n')).toBe(true);
|
|
511
|
-
});
|
|
512
|
-
});
|
|
513
|
-
// テストの目的: tableCell がタブで終わること
|
|
514
|
-
describe('tableCell の末尾タブ検証', () => {
|
|
515
|
-
it('Given: tableCell ノード, When: extractTextFromAdfNode を呼び出す, Then: 末尾にタブが付く', () => {
|
|
516
|
-
// Given: tableCell ノード
|
|
517
|
-
const node = {
|
|
518
|
-
content: [{ content: [{ text: 'セル', type: 'text' }], type: 'paragraph' }],
|
|
519
|
-
type: 'tableCell',
|
|
520
|
-
};
|
|
521
|
-
// When: extractTextFromAdfNode を呼び出す
|
|
522
|
-
const result = extractTextFromAdfNode(node);
|
|
523
|
-
// Then: 末尾にタブが付く
|
|
524
|
-
expect(result).toBe('セル\t');
|
|
525
|
-
expect(result.endsWith('\t')).toBe(true);
|
|
526
|
-
});
|
|
527
|
-
});
|
|
528
|
-
// テストの目的: tableRow が末尾の空白を削除して改行を追加すること
|
|
529
|
-
describe('tableRow の処理検証', () => {
|
|
530
|
-
it('Given: tableRow ノード, When: extractTextFromAdfNode を呼び出す, Then: 末尾のタブが削除されて改行が付く', () => {
|
|
531
|
-
// Given: tableRow ノード
|
|
532
|
-
const node = {
|
|
533
|
-
content: [
|
|
534
|
-
{ content: [{ content: [{ text: 'A', type: 'text' }], type: 'paragraph' }], type: 'tableCell' },
|
|
535
|
-
{ content: [{ content: [{ text: 'B', type: 'text' }], type: 'paragraph' }], type: 'tableCell' },
|
|
536
|
-
],
|
|
537
|
-
type: 'tableRow',
|
|
538
|
-
};
|
|
539
|
-
// When: extractTextFromAdfNode を呼び出す
|
|
540
|
-
const result = extractTextFromAdfNode(node);
|
|
541
|
-
// Then: 末尾の空白が削除されて改行が付く
|
|
542
|
-
expect(result).toBe('A\tB\n');
|
|
543
|
-
});
|
|
544
|
-
});
|
|
545
|
-
// テストの目的: 未知のノードタイプが空文字列を返すこと
|
|
546
|
-
describe('未知のノードタイプの処理', () => {
|
|
547
|
-
it('Given: 未知のノードタイプ, When: extractTextFromAdfNode を呼び出す, Then: 空文字列が返される', () => {
|
|
548
|
-
// Given: 未知のノードタイプ
|
|
549
|
-
const node = { type: 'unknownType' };
|
|
550
|
-
// When: extractTextFromAdfNode を呼び出す
|
|
551
|
-
const result = extractTextFromAdfNode(node);
|
|
552
|
-
// Then: 空文字列が返される
|
|
553
|
-
expect(result).toBe('');
|
|
554
|
-
});
|
|
555
|
-
});
|
|
556
|
-
});
|
|
557
|
-
describe('decodeHtmlEntities (in-source testing)', () => {
|
|
558
|
-
// テストの目的: 各エンティティが正確に変換されること
|
|
559
|
-
describe('個別エンティティの変換検証', () => {
|
|
560
|
-
it('Given: , When: decodeHtmlEntities を呼び出す, Then: スペースに変換される', () => {
|
|
561
|
-
expect(decodeHtmlEntities(' ')).toBe(' ');
|
|
562
|
-
});
|
|
563
|
-
it('Given: &, When: decodeHtmlEntities を呼び出す, Then: & に変換される', () => {
|
|
564
|
-
expect(decodeHtmlEntities('&')).toBe('&');
|
|
565
|
-
});
|
|
566
|
-
it('Given: <, When: decodeHtmlEntities を呼び出す, Then: < に変換される', () => {
|
|
567
|
-
expect(decodeHtmlEntities('<')).toBe('<');
|
|
568
|
-
});
|
|
569
|
-
it('Given: >, When: decodeHtmlEntities を呼び出す, Then: > に変換される', () => {
|
|
570
|
-
expect(decodeHtmlEntities('>')).toBe('>');
|
|
571
|
-
});
|
|
572
|
-
it('Given: ", When: decodeHtmlEntities を呼び出す, Then: " に変換される', () => {
|
|
573
|
-
expect(decodeHtmlEntities('"')).toBe('"');
|
|
574
|
-
});
|
|
575
|
-
it("Given: ', When: decodeHtmlEntities を呼び出す, Then: ' に変換される", () => {
|
|
576
|
-
expect(decodeHtmlEntities(''')).toBe("'");
|
|
577
|
-
});
|
|
578
|
-
it("Given: ', When: decodeHtmlEntities を呼び出す, Then: ' に変換される", () => {
|
|
579
|
-
expect(decodeHtmlEntities(''')).toBe("'");
|
|
580
|
-
});
|
|
581
|
-
it('Given: 数値文字参照 A, When: decodeHtmlEntities を呼び出す, Then: A に変換される', () => {
|
|
582
|
-
expect(decodeHtmlEntities('A')).toBe('A');
|
|
583
|
-
});
|
|
584
|
-
it('Given: 数値文字参照 あ, When: decodeHtmlEntities を呼び出す, Then: あ に変換される', () => {
|
|
585
|
-
expect(decodeHtmlEntities('あ')).toBe('あ');
|
|
586
|
-
});
|
|
587
|
-
});
|
|
588
|
-
// テストの目的: 複数のエンティティが正しい順序で変換されること
|
|
589
|
-
describe('変換順序の検証', () => {
|
|
590
|
-
it('Given: &nbsp;, When: decodeHtmlEntities を呼び出す, Then: に変換される(& が先に処理される)', () => {
|
|
591
|
-
// &nbsp; → になること( → スペース にはならない)
|
|
592
|
-
expect(decodeHtmlEntities('&nbsp;')).toBe(' ');
|
|
593
|
-
});
|
|
594
|
-
it('Given: &lt;, When: decodeHtmlEntities を呼び出す, Then: < に変換される(連鎖的に置換される)', () => {
|
|
595
|
-
// & -> & になり、その後 < -> < になる
|
|
596
|
-
expect(decodeHtmlEntities('&lt;')).toBe('<');
|
|
597
|
-
});
|
|
598
|
-
});
|
|
599
|
-
});
|
|
600
|
-
describe('convertImagesToPlaceholders (in-source testing)', () => {
|
|
601
|
-
// テストの目的: 画像タグが '[画像: ファイル名]' に変換されること
|
|
602
|
-
describe('画像プレースホルダーの検証', () => {
|
|
603
|
-
it('Given: ac:image タグ, When: convertImagesToPlaceholders を呼び出す, Then: [画像: filename] 形式になる', () => {
|
|
604
|
-
const html = '<ac:image><ri:attachment ri:filename="test.png"/></ac:image>';
|
|
605
|
-
const result = convertImagesToPlaceholders(html);
|
|
606
|
-
expect(result).toBe('[画像: test.png]');
|
|
607
|
-
});
|
|
608
|
-
it('Given: 日本語ファイル名の画像, When: convertImagesToPlaceholders を呼び出す, Then: ファイル名がそのまま含まれる', () => {
|
|
609
|
-
const html = '<ac:image><ri:attachment ri:filename="テスト画像.png"/></ac:image>';
|
|
610
|
-
const result = convertImagesToPlaceholders(html);
|
|
611
|
-
expect(result).toBe('[画像: テスト画像.png]');
|
|
612
|
-
});
|
|
613
|
-
it('Given: 空のファイル名, When: convertImagesToPlaceholders を呼び出す, Then: [画像: ] となる', () => {
|
|
614
|
-
const html = '<ac:image><ri:attachment ri:filename=""/></ac:image>';
|
|
615
|
-
const result = convertImagesToPlaceholders(html);
|
|
616
|
-
expect(result).toBe('[画像: ]');
|
|
617
|
-
});
|
|
618
|
-
});
|
|
619
|
-
});
|
|
620
|
-
describe('convertUserLinksToPlaceholders (in-source testing)', () => {
|
|
621
|
-
// テストの目的: ユーザーリンクが '[ユーザー]' に変換されること
|
|
622
|
-
describe('ユーザープレースホルダーの検証', () => {
|
|
623
|
-
it('Given: ri:user タグ, When: convertUserLinksToPlaceholders を呼び出す, Then: [ユーザー] になる', () => {
|
|
624
|
-
const html = '<ac:link><ri:user ri:account-id="123"/></ac:link>';
|
|
625
|
-
const result = convertUserLinksToPlaceholders(html);
|
|
626
|
-
expect(result).toBe('[ユーザー]');
|
|
627
|
-
});
|
|
628
|
-
it('Given: 複数のユーザーリンク, When: convertUserLinksToPlaceholders を呼び出す, Then: それぞれ [ユーザー] になる', () => {
|
|
629
|
-
const html = '<ac:link><ri:user ri:account-id="1"/></ac:link>と<ac:link><ri:user ri:account-id="2"/></ac:link>';
|
|
630
|
-
const result = convertUserLinksToPlaceholders(html);
|
|
631
|
-
expect(result).toBe('[ユーザー]と[ユーザー]');
|
|
632
|
-
});
|
|
633
|
-
});
|
|
634
|
-
});
|
|
635
|
-
describe('normalizeWhitespace (in-source testing)', () => {
|
|
636
|
-
// テストの目的: 連続空白が単一スペースに正規化されること
|
|
637
|
-
describe('空白正規化の検証', () => {
|
|
638
|
-
it('Given: 連続スペース, When: normalizeWhitespace を呼び出す, Then: 単一スペースになる', () => {
|
|
639
|
-
expect(normalizeWhitespace('a b')).toBe('a b');
|
|
640
|
-
});
|
|
641
|
-
it('Given: 連続タブ, When: normalizeWhitespace を呼び出す, Then: 単一スペースになる', () => {
|
|
642
|
-
expect(normalizeWhitespace('a\t\t\tb')).toBe('a b');
|
|
643
|
-
});
|
|
644
|
-
it('Given: 空行, When: normalizeWhitespace を呼び出す, Then: 空行が削除される', () => {
|
|
645
|
-
expect(normalizeWhitespace('a\n\n\nb')).toBe('a\nb');
|
|
646
|
-
});
|
|
647
|
-
it('Given: 先頭末尾の空白, When: normalizeWhitespace を呼び出す, Then: トリムされる', () => {
|
|
648
|
-
expect(normalizeWhitespace(' a ')).toBe('a');
|
|
649
|
-
});
|
|
650
|
-
});
|
|
651
|
-
});
|
|
652
|
-
describe('isAdfDocument (in-source testing)', () => {
|
|
653
|
-
// テストの目的: ADF ドキュメント判定が正しく動作すること
|
|
654
|
-
describe('ADF 判定の検証', () => {
|
|
655
|
-
it('Given: 有効な ADF, When: isAdfDocument を呼び出す, Then: true が返される', () => {
|
|
656
|
-
const doc = { content: [], type: 'doc', version: 1 };
|
|
657
|
-
expect(isAdfDocument(doc)).toBe(true);
|
|
658
|
-
});
|
|
659
|
-
it('Given: type が doc でない, When: isAdfDocument を呼び出す, Then: false が返される', () => {
|
|
660
|
-
const doc = { content: [], type: 'paragraph' };
|
|
661
|
-
expect(isAdfDocument(doc)).toBe(false);
|
|
662
|
-
});
|
|
663
|
-
it('Given: content がない, When: isAdfDocument を呼び出す, Then: false が返される', () => {
|
|
664
|
-
const doc = { type: 'doc', version: 1 };
|
|
665
|
-
expect(isAdfDocument(doc)).toBe(false);
|
|
666
|
-
});
|
|
667
|
-
it('Given: content が配列でない, When: isAdfDocument を呼び出す, Then: false が返される', () => {
|
|
668
|
-
const doc = { content: 'string', type: 'doc', version: 1 };
|
|
669
|
-
expect(isAdfDocument(doc)).toBe(false);
|
|
670
|
-
});
|
|
671
|
-
it('Given: null, When: isAdfDocument を呼び出す, Then: false が返される', () => {
|
|
672
|
-
expect(isAdfDocument(null)).toBe(false);
|
|
673
|
-
});
|
|
674
|
-
it('Given: プリミティブ値, When: isAdfDocument を呼び出す, Then: false が返される', () => {
|
|
675
|
-
expect(isAdfDocument('string')).toBe(false);
|
|
676
|
-
expect(isAdfDocument(123)).toBe(false);
|
|
677
|
-
expect(isAdfDocument(true)).toBe(false);
|
|
678
|
-
});
|
|
679
|
-
});
|
|
680
|
-
});
|
|
681
|
-
}
|