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,270 @@
1
+ /**
2
+ * Markdown 変換ユーティリティ
3
+ *
4
+ * TurndownService の設定と前処理を提供する
5
+ */
6
+ import TurndownService from 'turndown';
7
+ import { gfm } from 'turndown-plugin-gfm';
8
+ /**
9
+ * 前処理: 無視する要素を削除し、Confluence 固有タグを処理
10
+ *
11
+ * @param html HTML 文字列
12
+ * @param attachmentPaths 添付ファイルマッピング
13
+ * @returns 前処理済み HTML
14
+ */
15
+ export const preprocessHtmlForMarkdown = (html, attachmentPaths) => {
16
+ let result = html;
17
+ // colgroup/col を削除
18
+ result = result.replace(/<colgroup[\s\S]*?<\/colgroup>/gi, '');
19
+ result = result.replace(/<col[^>]*\/?>/gi, '');
20
+ // data-highlight-colour 属性を削除
21
+ result = result.replace(/\s*data-highlight-colour="[^"]*"/gi, '');
22
+ // ac:local-id, local-id 属性を削除
23
+ result = result.replace(/\s*(ac:)?local-id="[^"]*"/gi, '');
24
+ // ac:inline-comment-marker を内容のみに置換
25
+ result = result.replace(/<ac:inline-comment-marker[^>]*>([\s\S]*?)<\/ac:inline-comment-marker>/gi, '$1');
26
+ // CDATA セクションを処理
27
+ result = result.replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1');
28
+ // --------------------------------------------------
29
+ // Confluence 固有タグを標準 HTML タグに変換(turndown が認識できる形式へ)
30
+ // --------------------------------------------------
31
+ // ac:image + ac:caption を <img> + <figcaption> に変換(キャプション付きを先に処理)
32
+ 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) => {
33
+ const localPath = attachmentPaths?.[filename] || filename;
34
+ return `<figure><img src="${localPath}" alt="${filename}"><figcaption>${caption}</figcaption></figure>`;
35
+ });
36
+ // ac:image を <img> に変換(キャプションなしの残り)
37
+ result = result.replace(/<ac:image[^>]*>[\s\S]*?<ri:attachment[^>]*ri:filename="([^"]*)"[^>]*\/?>[\s\S]*?<\/ac:image>/gi, (_match, filename) => {
38
+ const localPath = attachmentPaths?.[filename] || filename;
39
+ return `<img src="${localPath}" alt="${filename}">`;
40
+ });
41
+ // ac:structured-macro name="code" を <pre><code> に変換
42
+ result = result.replace(/<ac:structured-macro[^>]*ac:name="code"[^>]*>([\s\S]*?)<\/ac:structured-macro>/gi, (_match, innerContent) => {
43
+ // language パラメータを抽出
44
+ const langMatch = innerContent.match(/<ac:parameter[^>]*ac:name="language"[^>]*>([^<]*)<\/ac:parameter>/i);
45
+ const lang = langMatch?.[1] || '';
46
+ // plain-text-body の内容を抽出
47
+ const bodyMatch = innerContent.match(/<ac:plain-text-body[^>]*>([\s\S]*?)<\/ac:plain-text-body>/i);
48
+ const code = bodyMatch?.[1] || '';
49
+ // turndown が認識できる形式に変換
50
+ const langClass = lang ? ` class="language-${lang}"` : '';
51
+ return `<pre><code${langClass}>${code}</code></pre>`;
52
+ });
53
+ // ac:structured-macro name="info/note/tip/warning" を GitHub Alerts 形式の blockquote に変換
54
+ const alertMacros = ['info', 'note', 'tip', 'warning'];
55
+ const alertTypeMap = {
56
+ info: 'NOTE',
57
+ note: 'NOTE',
58
+ tip: 'TIP',
59
+ warning: 'WARNING',
60
+ };
61
+ for (const macroName of alertMacros) {
62
+ 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');
63
+ result = result.replace(pattern, (_match, content) => {
64
+ const alertType = alertTypeMap[macroName] || 'NOTE';
65
+ // 専用のマーカー属性を持つ blockquote に変換
66
+ return `<blockquote data-github-alert="${alertType}">${content}</blockquote>`;
67
+ });
68
+ }
69
+ // ac:structured-macro name="toc" を [TOC] マーカーに変換(markdown-toc 互換形式)
70
+ result = result.replace(/<ac:structured-macro[^>]*ac:name="toc"[^>]*>[\s\S]*?<\/ac:structured-macro>/gi, '<p>[TOC]</p>');
71
+ // ac:structured-macro name="anchor" を <a id="name"></a> に変換
72
+ result = result.replace(/<ac:structured-macro[^>]*ac:name="anchor"[^>]*>([\s\S]*?)<\/ac:structured-macro>/gi, (_match, innerContent) => {
73
+ // パラメータの値を抽出(ac:name="" または ac:name="..." 両方対応)
74
+ const paramMatch = innerContent.match(/<ac:parameter[^>]*>([^<]*)<\/ac:parameter>/i);
75
+ const anchorName = paramMatch?.[1]?.trim() || '';
76
+ return anchorName ? `<a id="${anchorName}"></a>` : '';
77
+ });
78
+ // ac:structured-macro name="expand" を <details><summary>...</summary>...</details> に変換
79
+ result = result.replace(/<ac:structured-macro[^>]*ac:name="expand"[^>]*>([\s\S]*?)<\/ac:structured-macro>/gi, (_match, innerContent) => {
80
+ // title パラメータを抽出
81
+ const titleMatch = innerContent.match(/<ac:parameter[^>]*ac:name="title"[^>]*>([^<]*)<\/ac:parameter>/i);
82
+ const title = titleMatch?.[1] || '展開';
83
+ // rich-text-body の内容を抽出
84
+ const bodyMatch = innerContent.match(/<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>/i);
85
+ const body = bodyMatch?.[1] || '';
86
+ return `<details><summary>${title}</summary>${body}</details>`;
87
+ });
88
+ // ac:structured-macro name="excerpt" を処理(hidden=true は削除、それ以外は内容を出力)
89
+ result = result.replace(/<ac:structured-macro[^>]*ac:name="excerpt"[^>]*>([\s\S]*?)<\/ac:structured-macro>/gi, (_match, innerContent) => {
90
+ // hidden パラメータをチェック
91
+ const hiddenMatch = innerContent.match(/<ac:parameter[^>]*ac:name="hidden"[^>]*>([^<]*)<\/ac:parameter>/i);
92
+ const isHidden = hiddenMatch?.[1]?.toLowerCase() === 'true';
93
+ if (isHidden) {
94
+ return ''; // hidden=true の場合は削除
95
+ }
96
+ // rich-text-body の内容を抽出
97
+ const bodyMatch = innerContent.match(/<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>/i);
98
+ return bodyMatch?.[1] || '';
99
+ });
100
+ // ac:structured-macro name="excerpt-include" をプレースホルダーに変換
101
+ result = result.replace(/<ac:structured-macro[^>]*ac:name="excerpt-include"[^>]*>([\s\S]*?)<\/ac:structured-macro>/gi, (_match, innerContent) => {
102
+ // ページ名を抽出
103
+ const pageMatch = innerContent.match(/ri:content-title="([^"]*)"/i);
104
+ const pageName = pageMatch?.[1] || '不明なページ';
105
+ return `<p>[抜粋: ${pageName}]</p>`;
106
+ });
107
+ // ac:structured-macro name="toc-zone" を [TOC] + 内容に変換
108
+ result = result.replace(/<ac:structured-macro[^>]*ac:name="toc-zone"[^>]*>([\s\S]*?)<\/ac:structured-macro>/gi, (_match, innerContent) => {
109
+ // rich-text-body の内容を抽出
110
+ const bodyMatch = innerContent.match(/<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>/i);
111
+ const body = bodyMatch?.[1] || '';
112
+ return `<p>[TOC]</p>${body}`;
113
+ });
114
+ // ac:link + ri:page を Markdown リンクに変換(CDATA 処理後のパターンも対応)
115
+ result = result.replace(/<ac:link[^>]*>[\s\S]*?<ri:page[^>]*ri:content-title="([^"]*)"[^>]*\/>[\s\S]*?<ac:plain-text-link-body[^>]*>([^<]*)<\/ac:plain-text-link-body>[\s\S]*?<\/ac:link>/gi, (_match, pageTitle, linkText) => {
116
+ return `<a href="${pageTitle}">${linkText}</a>`;
117
+ });
118
+ // ac:link + ri:page(リンクテキストなし)を処理
119
+ result = result.replace(/<ac:link[^>]*>[\s\S]*?<ri:page[^>]*ri:content-title="([^"]*)"[^>]*\/>[\s\S]*?<\/ac:link>/gi, (_match, pageTitle) => {
120
+ return `<a href="${pageTitle}">${pageTitle}</a>`;
121
+ });
122
+ // ac:structured-macro name="column" を td タグに変換(先に処理)
123
+ result = result.replace(/<ac:structured-macro[^>]*ac:name="column"[^>]*>[\s\S]*?<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>[\s\S]*?<\/ac:structured-macro>/gi, (_match, columnBody) => {
124
+ return `<td>${columnBody}</td>`;
125
+ });
126
+ // ac:structured-macro name="section" をテーブルに変換(column 処理後)
127
+ result = result.replace(/<ac:structured-macro[^>]*ac:name="section"[^>]*>[\s\S]*?<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>[\s\S]*?<\/ac:structured-macro>/gi, (_match, sectionBody) => {
128
+ // td タグがあればテーブルとして出力
129
+ if (sectionBody.includes('<td>')) {
130
+ return `<table><tr>${sectionBody.trim()}</tr></table>`;
131
+ }
132
+ return sectionBody;
133
+ });
134
+ return result;
135
+ };
136
+ /**
137
+ * TurndownService インスタンスを作成する(共通設定)
138
+ * Jira ADF と Confluence Storage Format の両方で使用する
139
+ *
140
+ * @returns 設定済みの TurndownService インスタンス
141
+ */
142
+ export const createTurndownService = () => {
143
+ const turndownService = new TurndownService({
144
+ bulletListMarker: '-',
145
+ codeBlockStyle: 'fenced',
146
+ emDelimiter: '*',
147
+ headingStyle: 'atx',
148
+ strongDelimiter: '**',
149
+ });
150
+ // GFM プラグイン(テーブル、取り消し線など)を使用
151
+ turndownService.use(gfm);
152
+ // カスタムルール: キャプション付き画像(<figure>)
153
+ turndownService.addRule('figureWithCaption', {
154
+ filter: (node) => {
155
+ return node.nodeName === 'FIGURE';
156
+ },
157
+ replacement: (_content, node) => {
158
+ const element = node;
159
+ const img = element.querySelector('img');
160
+ const figcaption = element.querySelector('figcaption');
161
+ if (img) {
162
+ const src = img.getAttribute('src') || '';
163
+ const alt = img.getAttribute('alt') || '';
164
+ let result = `![${alt}](${src})`;
165
+ if (figcaption) {
166
+ const captionText = figcaption.textContent?.trim() || '';
167
+ if (captionText) {
168
+ result += `\n\n*${captionText}*`;
169
+ }
170
+ }
171
+ return result;
172
+ }
173
+ return '';
174
+ },
175
+ });
176
+ // カスタムルール: GitHub Alerts(<blockquote data-github-alert="...">)
177
+ turndownService.addRule('githubAlerts', {
178
+ filter: (node) => {
179
+ if (node.nodeName !== 'BLOCKQUOTE')
180
+ return false;
181
+ return node.hasAttribute('data-github-alert');
182
+ },
183
+ replacement: (content, node) => {
184
+ const element = node;
185
+ const alertType = element.getAttribute('data-github-alert') || 'NOTE';
186
+ // 内容を再帰的に Markdown 変換
187
+ const innerMarkdown = content.trim();
188
+ // 各行に > プレフィックス付加
189
+ const lines = innerMarkdown.split('\n');
190
+ const quotedContent = lines.map((line) => `> ${line}`).join('\n');
191
+ return `\n> [!${alertType}]\n${quotedContent}\n`;
192
+ },
193
+ });
194
+ // カスタムルール: 色変更テキスト(HTML のまま出力)
195
+ turndownService.addRule('coloredText', {
196
+ filter: (node) => {
197
+ if (node.nodeName !== 'SPAN')
198
+ return false;
199
+ const style = node.getAttribute('style') || '';
200
+ return style.includes('color:') || style.includes('color :');
201
+ },
202
+ replacement: (_content, node) => {
203
+ // HTML のまま出力
204
+ return node.outerHTML;
205
+ },
206
+ });
207
+ // カスタムルール: アンカータグ(id 属性付き)を HTML のまま出力
208
+ turndownService.addRule('anchorTag', {
209
+ filter: (node) => {
210
+ if (node.nodeName !== 'A')
211
+ return false;
212
+ return node.hasAttribute('id');
213
+ },
214
+ replacement: (_content, node) => {
215
+ return node.outerHTML;
216
+ },
217
+ });
218
+ // カスタムルール: 背景色テキスト(HTML のまま出力)
219
+ turndownService.addRule('highlightedText', {
220
+ filter: (node) => {
221
+ if (node.nodeName !== 'SPAN')
222
+ return false;
223
+ const style = node.getAttribute('style') || '';
224
+ return style.includes('background-color');
225
+ },
226
+ replacement: (_content, node) => {
227
+ // HTML のまま出力
228
+ return node.outerHTML;
229
+ },
230
+ });
231
+ // カスタムルール: date ノード用(<time> タグから日付を抽出)
232
+ turndownService.addRule('dateNode', {
233
+ filter: 'time',
234
+ replacement: (_, node) => {
235
+ return node.getAttribute('datetime') || '';
236
+ },
237
+ });
238
+ // カスタムルール: expand 用(<details> タグを HTML のまま出力)
239
+ turndownService.addRule('expandDetails', {
240
+ filter: 'details',
241
+ replacement: (content, node) => {
242
+ const element = node;
243
+ const summary = element.querySelector('summary')?.textContent || '展開';
244
+ return `\n<details>\n<summary>${summary}</summary>\n\n${content.trim()}\n\n</details>\n`;
245
+ },
246
+ });
247
+ // カスタムルール: border スタイル付き画像(HTML のまま出力)
248
+ turndownService.addRule('borderedImage', {
249
+ filter: (node) => {
250
+ if (node.nodeName !== 'IMG')
251
+ return false;
252
+ const style = node.getAttribute('style') || '';
253
+ return style.includes('border');
254
+ },
255
+ replacement: (_content, node) => {
256
+ return node.outerHTML;
257
+ },
258
+ });
259
+ // カスタムルール: 全テーブルを HTML のまま出力(シンプル・確実)
260
+ // セル内リスト、画像、複数段落、コードなど全ケースで安定
261
+ // Confluence の複雑なテーブルも確実に表示
262
+ // Markdown 対応ビューアでも HTML テーブルは表示可能
263
+ turndownService.addRule('allTables', {
264
+ filter: 'table',
265
+ replacement: (_content, node) => {
266
+ return '\n\n' + node.outerHTML + '\n\n';
267
+ },
268
+ });
269
+ return turndownService;
270
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Confluence Storage Format → PlainText 変換
3
+ */
4
+ /**
5
+ * HTML エンティティをデコードする
6
+ *
7
+ * @param text エンコードされた文字列
8
+ * @returns デコードされた文字列
9
+ */
10
+ export declare const decodeHtmlEntities: (text: string) => string;
11
+ /**
12
+ * CDATA セクションからテキストを抽出する
13
+ *
14
+ * @param html HTML 文字列
15
+ * @returns CDATA セクションを処理した文字列
16
+ */
17
+ export declare const extractCdata: (html: string) => string;
18
+ /**
19
+ * Confluence マクロのパラメータからテキストを抽出する
20
+ *
21
+ * @param html HTML 文字列
22
+ * @returns 処理された文字列
23
+ */
24
+ export declare const extractMacroParameters: (html: string) => string;
25
+ /**
26
+ * 画像タグをプレースホルダーに変換する
27
+ *
28
+ * @param html HTML 文字列
29
+ * @returns 処理された文字列
30
+ */
31
+ export declare const convertImagesToPlaceholders: (html: string) => string;
32
+ /**
33
+ * ユーザーリンクをプレースホルダーに変換する
34
+ *
35
+ * @param html HTML 文字列
36
+ * @returns 処理された文字列
37
+ */
38
+ export declare const convertUserLinksToPlaceholders: (html: string) => string;
39
+ /**
40
+ * ブロック要素のタグを処理して改行を適切に挿入する
41
+ *
42
+ * @param html HTML 文字列
43
+ * @returns 処理された文字列
44
+ */
45
+ export declare const processBlockElements: (html: string) => string;
46
+ /**
47
+ * HTML タグを除去する
48
+ *
49
+ * @param html HTML 文字列
50
+ * @returns タグを除去した文字列
51
+ */
52
+ export declare const stripHtmlTags: (html: string) => string;
53
+ /**
54
+ * 連続する空白を正規化する
55
+ *
56
+ * @param text テキスト
57
+ * @returns 正規化されたテキスト
58
+ */
59
+ export declare const normalizeWhitespace: (text: string) => string;
60
+ /**
61
+ * Confluence の Storage Format(XHTML)をプレーンテキストに変換する
62
+ *
63
+ * @param storageFormat Storage Format 文字列
64
+ * @returns プレーンテキスト
65
+ */
66
+ export declare const convertStorageFormatToPlainText: (storageFormat: string | null | undefined) => string;
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Confluence Storage Format → PlainText 変換
3
+ */
4
+ /**
5
+ * HTML エンティティをデコードする
6
+ *
7
+ * @param text エンコードされた文字列
8
+ * @returns デコードされた文字列
9
+ */
10
+ export const decodeHtmlEntities = (text) => {
11
+ return text
12
+ .replace(/&nbsp;/g, ' ')
13
+ .replace(/&amp;/g, '&')
14
+ .replace(/&lt;/g, '<')
15
+ .replace(/&gt;/g, '>')
16
+ .replace(/&quot;/g, '"')
17
+ .replace(/&#39;/g, "'")
18
+ .replace(/&#x27;/g, "'")
19
+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number.parseInt(code, 10)));
20
+ };
21
+ /**
22
+ * CDATA セクションからテキストを抽出する
23
+ *
24
+ * @param html HTML 文字列
25
+ * @returns CDATA セクションを処理した文字列
26
+ */
27
+ export const extractCdata = (html) => {
28
+ return html.replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1');
29
+ };
30
+ /**
31
+ * Confluence マクロのパラメータからテキストを抽出する
32
+ *
33
+ * @param html HTML 文字列
34
+ * @returns 処理された文字列
35
+ */
36
+ export const extractMacroParameters = (html) => {
37
+ // ac:parameter タグから title などのテキストを抽出
38
+ return html.replace(/<ac:parameter[^>]*ac:name="title"[^>]*>([^<]*)<\/ac:parameter>/g, '$1');
39
+ };
40
+ /**
41
+ * 画像タグをプレースホルダーに変換する
42
+ *
43
+ * @param html HTML 文字列
44
+ * @returns 処理された文字列
45
+ */
46
+ export const convertImagesToPlaceholders = (html) => {
47
+ // ac:image タグと ri:attachment から画像ファイル名を抽出
48
+ return html.replace(/<ac:image[^>]*>[\s\S]*?<ri:attachment\s+ri:filename="([^"]*)"[^>]*\/>[\s\S]*?<\/ac:image>/g, '[画像: $1]');
49
+ };
50
+ /**
51
+ * ユーザーリンクをプレースホルダーに変換する
52
+ *
53
+ * @param html HTML 文字列
54
+ * @returns 処理された文字列
55
+ */
56
+ export const convertUserLinksToPlaceholders = (html) => {
57
+ // ri:user タグをプレースホルダーに変換
58
+ return html.replace(/<ac:link[^>]*>[\s\S]*?<ri:user[^>]*\/>[\s\S]*?<\/ac:link>/g, '[ユーザー]');
59
+ };
60
+ /**
61
+ * ブロック要素のタグを処理して改行を適切に挿入する
62
+ *
63
+ * @param html HTML 文字列
64
+ * @returns 処理された文字列
65
+ */
66
+ export const processBlockElements = (html) => {
67
+ // 閉じタグの前後に改行マーカーを追加
68
+ let result = html;
69
+ // パラグラフと見出しの後に改行
70
+ result = result.replace(/<\/(p|h[1-6])>/gi, '</$1>\n');
71
+ // リストアイテムの後に改行
72
+ result = result.replace(/<\/li>/gi, '</li>\n');
73
+ // テーブル行の後に改行
74
+ result = result.replace(/<\/tr>/gi, '</tr>\n');
75
+ // テーブルセルの後にタブ
76
+ result = result.replace(/<\/(td|th)>/gi, '\t</$1>');
77
+ // br タグを改行に変換
78
+ result = result.replace(/<br\s*\/?>/gi, '\n');
79
+ // blockquote の後に改行
80
+ result = result.replace(/<\/blockquote>/gi, '</blockquote>\n');
81
+ return result;
82
+ };
83
+ /**
84
+ * HTML タグを除去する
85
+ *
86
+ * @param html HTML 文字列
87
+ * @returns タグを除去した文字列
88
+ */
89
+ export const stripHtmlTags = (html) => {
90
+ return html.replace(/<[^>]*>/g, '');
91
+ };
92
+ /**
93
+ * 連続する空白を正規化する
94
+ *
95
+ * @param text テキスト
96
+ * @returns 正規化されたテキスト
97
+ */
98
+ export const normalizeWhitespace = (text) => {
99
+ // 行ごとに処理
100
+ const lines = text.split('\n');
101
+ const normalizedLines = lines.map((line) => {
102
+ // 行内の連続する空白を単一スペースに
103
+ return line.replace(/[ \t]+/g, ' ').trim();
104
+ });
105
+ // 空行を除去して結合
106
+ return normalizedLines.filter((line) => line !== '').join('\n');
107
+ };
108
+ /**
109
+ * Confluence の Storage Format(XHTML)をプレーンテキストに変換する
110
+ *
111
+ * @param storageFormat Storage Format 文字列
112
+ * @returns プレーンテキスト
113
+ */
114
+ export const convertStorageFormatToPlainText = (storageFormat) => {
115
+ // null または undefined の場合は空文字列を返す
116
+ if (storageFormat === null || storageFormat === undefined || storageFormat === '') {
117
+ return '';
118
+ }
119
+ let result = storageFormat;
120
+ // CDATA セクションを処理
121
+ result = extractCdata(result);
122
+ // マクロパラメータからテキストを抽出
123
+ result = extractMacroParameters(result);
124
+ // 画像をプレースホルダーに変換
125
+ result = convertImagesToPlaceholders(result);
126
+ // ユーザーリンクをプレースホルダーに変換
127
+ result = convertUserLinksToPlaceholders(result);
128
+ // ブロック要素を処理
129
+ result = processBlockElements(result);
130
+ // HTML タグを除去
131
+ result = stripHtmlTags(result);
132
+ // HTML エンティティをデコード
133
+ result = decodeHtmlEntities(result);
134
+ // 空白を正規化
135
+ result = normalizeWhitespace(result);
136
+ return result;
137
+ };
138
+ // ============================================================
139
+ // In-source Testing(プライベート関数のテスト)
140
+ // ============================================================
141
+ if (import.meta.vitest) {
142
+ const { describe, expect, it } = import.meta.vitest;
143
+ describe('decodeHtmlEntities (in-source testing)', () => {
144
+ // テストの目的: 各エンティティが正確に変換されること
145
+ describe('個別エンティティの変換検証', () => {
146
+ it('Given: &nbsp;, When: decodeHtmlEntities を呼び出す, Then: スペースに変換される', () => {
147
+ expect(decodeHtmlEntities('&nbsp;')).toBe(' ');
148
+ });
149
+ it('Given: &amp;, When: decodeHtmlEntities を呼び出す, Then: & に変換される', () => {
150
+ expect(decodeHtmlEntities('&amp;')).toBe('&');
151
+ });
152
+ it('Given: &lt;, When: decodeHtmlEntities を呼び出す, Then: < に変換される', () => {
153
+ expect(decodeHtmlEntities('&lt;')).toBe('<');
154
+ });
155
+ it('Given: &gt;, When: decodeHtmlEntities を呼び出す, Then: > に変換される', () => {
156
+ expect(decodeHtmlEntities('&gt;')).toBe('>');
157
+ });
158
+ it('Given: &quot;, When: decodeHtmlEntities を呼び出す, Then: " に変換される', () => {
159
+ expect(decodeHtmlEntities('&quot;')).toBe('"');
160
+ });
161
+ it("Given: &#39;, When: decodeHtmlEntities を呼び出す, Then: ' に変換される", () => {
162
+ expect(decodeHtmlEntities('&#39;')).toBe("'");
163
+ });
164
+ it("Given: &#x27;, When: decodeHtmlEntities を呼び出す, Then: ' に変換される", () => {
165
+ expect(decodeHtmlEntities('&#x27;')).toBe("'");
166
+ });
167
+ it('Given: 数値文字参照 &#65;, When: decodeHtmlEntities を呼び出す, Then: A に変換される', () => {
168
+ expect(decodeHtmlEntities('&#65;')).toBe('A');
169
+ });
170
+ it('Given: 数値文字参照 &#12354;, When: decodeHtmlEntities を呼び出す, Then: あ に変換される', () => {
171
+ expect(decodeHtmlEntities('&#12354;')).toBe('あ');
172
+ });
173
+ });
174
+ // テストの目的: 複数のエンティティが正しい順序で変換されること
175
+ describe('変換順序の検証', () => {
176
+ it('Given: &amp;nbsp;, When: decodeHtmlEntities を呼び出す, Then: &nbsp; に変換される(&amp; が先に処理される)', () => {
177
+ // &amp;nbsp; → &nbsp; になること(&nbsp; → スペース にはならない)
178
+ expect(decodeHtmlEntities('&amp;nbsp;')).toBe('&nbsp;');
179
+ });
180
+ it('Given: &amp;lt;, When: decodeHtmlEntities を呼び出す, Then: < に変換される(連鎖的に置換される)', () => {
181
+ // &amp; -> & になり、その後 &lt; -> < になる
182
+ expect(decodeHtmlEntities('&amp;lt;')).toBe('<');
183
+ });
184
+ });
185
+ });
186
+ describe('convertImagesToPlaceholders (in-source testing)', () => {
187
+ // テストの目的: 画像タグが '[画像: ファイル名]' に変換されること
188
+ describe('画像プレースホルダーの検証', () => {
189
+ it('Given: ac:image タグ, When: convertImagesToPlaceholders を呼び出す, Then: [画像: filename] 形式になる', () => {
190
+ const html = '<ac:image><ri:attachment ri:filename="test.png"/></ac:image>';
191
+ const result = convertImagesToPlaceholders(html);
192
+ expect(result).toBe('[画像: test.png]');
193
+ });
194
+ it('Given: 日本語ファイル名の画像, When: convertImagesToPlaceholders を呼び出す, Then: ファイル名がそのまま含まれる', () => {
195
+ const html = '<ac:image><ri:attachment ri:filename="テスト画像.png"/></ac:image>';
196
+ const result = convertImagesToPlaceholders(html);
197
+ expect(result).toBe('[画像: テスト画像.png]');
198
+ });
199
+ it('Given: 空のファイル名, When: convertImagesToPlaceholders を呼び出す, Then: [画像: ] となる', () => {
200
+ const html = '<ac:image><ri:attachment ri:filename=""/></ac:image>';
201
+ const result = convertImagesToPlaceholders(html);
202
+ expect(result).toBe('[画像: ]');
203
+ });
204
+ });
205
+ });
206
+ describe('convertUserLinksToPlaceholders (in-source testing)', () => {
207
+ // テストの目的: ユーザーリンクが '[ユーザー]' に変換されること
208
+ describe('ユーザープレースホルダーの検証', () => {
209
+ it('Given: ri:user タグ, When: convertUserLinksToPlaceholders を呼び出す, Then: [ユーザー] になる', () => {
210
+ const html = '<ac:link><ri:user ri:account-id="123"/></ac:link>';
211
+ const result = convertUserLinksToPlaceholders(html);
212
+ expect(result).toBe('[ユーザー]');
213
+ });
214
+ it('Given: 複数のユーザーリンク, When: convertUserLinksToPlaceholders を呼び出す, Then: それぞれ [ユーザー] になる', () => {
215
+ const html = '<ac:link><ri:user ri:account-id="1"/></ac:link>と<ac:link><ri:user ri:account-id="2"/></ac:link>';
216
+ const result = convertUserLinksToPlaceholders(html);
217
+ expect(result).toBe('[ユーザー]と[ユーザー]');
218
+ });
219
+ });
220
+ });
221
+ describe('normalizeWhitespace (in-source testing)', () => {
222
+ // テストの目的: 連続空白が単一スペースに正規化されること
223
+ describe('空白正規化の検証', () => {
224
+ it('Given: 連続スペース, When: normalizeWhitespace を呼び出す, Then: 単一スペースになる', () => {
225
+ expect(normalizeWhitespace('a b')).toBe('a b');
226
+ });
227
+ it('Given: 連続タブ, When: normalizeWhitespace を呼び出す, Then: 単一スペースになる', () => {
228
+ expect(normalizeWhitespace('a\t\t\tb')).toBe('a b');
229
+ });
230
+ it('Given: 空行, When: normalizeWhitespace を呼び出す, Then: 空行が削除される', () => {
231
+ expect(normalizeWhitespace('a\n\n\nb')).toBe('a\nb');
232
+ });
233
+ it('Given: 先頭末尾の空白, When: normalizeWhitespace を呼び出す, Then: トリムされる', () => {
234
+ expect(normalizeWhitespace(' a ')).toBe('a');
235
+ });
236
+ });
237
+ });
238
+ }
@@ -2,28 +2,19 @@
2
2
  * テキスト変換サービス
3
3
  *
4
4
  * Jira の ADF(Atlassian Document Format)と Confluence の Storage Format を
5
- * プレーンテキストに変換する機能を提供する。
6
- * また、Confluence の Storage Format を Markdown に変換する機能も提供する。
5
+ * プレーンテキストや Markdown に変換する機能を提供する。
7
6
  */
7
+ import type { AttachmentPathMapping } from './types.js';
8
+ export { convertAdfToPlainText } from './adf-to-plain-text.js';
9
+ export { convertStorageFormatToPlainText } from './storage-to-plain-text.js';
8
10
  /**
9
- * 添付ファイルパスのマッピング型
10
- * filename -> savedPath
11
- */
12
- type AttachmentPathMapping = Record<string, string>;
13
- /**
14
- * ADF(Atlassian Document Format)をプレーンテキストに変換する
11
+ * ADF(Atlassian Document Format)を Markdown に変換する
15
12
  *
16
13
  * @param adf ADF ドキュメント(オブジェクトまたは JSON 文字列)
17
- * @returns プレーンテキスト
18
- */
19
- export declare const convertAdfToPlainText: (adf: unknown) => string;
20
- /**
21
- * Confluence の Storage Format(XHTML)をプレーンテキストに変換する
22
- *
23
- * @param storageFormat Storage Format 文字列
24
- * @returns プレーンテキスト
14
+ * @param attachmentPaths 添付ファイル ID → ローカルパスのマッピング
15
+ * @returns Markdown 文字列
25
16
  */
26
- export declare const convertStorageFormatToPlainText: (storageFormat: string | null | undefined) => string;
17
+ export declare const convertAdfToMarkdown: (adf: unknown, attachmentPaths?: AttachmentPathMapping) => string;
27
18
  /**
28
19
  * Confluence Storage Format(XHTML)を Markdown に変換する
29
20
  *
@@ -32,4 +23,3 @@ export declare const convertStorageFormatToPlainText: (storageFormat: string | n
32
23
  * @returns Markdown 文字列
33
24
  */
34
25
  export declare const convertStorageFormatToMarkdown: (storageFormat: string | null | undefined, attachmentPaths?: AttachmentPathMapping) => string;
35
- export {};