line-markdown-parser 0.1.2__py3-none-any.whl

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,1565 @@
1
+ """Pure Markdown-to-Flex component rendering without I/O or logging."""
2
+
3
+ import re
4
+ import urllib.parse
5
+ from bisect import bisect_left, bisect_right
6
+ from collections.abc import Callable, Iterator
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ LINE_ACTION_URI_MAX_LENGTH = 1000
11
+ BODY_TEXT_COLOR = "#696969"
12
+ STRONG_TEXT_COLOR = "#555555"
13
+ LINK_TEXT_COLOR = "#0066FF"
14
+ INLINE_CODE_COLOR = "#C46A00"
15
+ MUTED_TEXT_COLOR = "#888888"
16
+ CODE_BLOCK_BACKGROUND = "#F5F5F5"
17
+ CODE_BLOCK_INDENT_SPACE = "\xa0"
18
+
19
+
20
+ def is_char_emoji(char):
21
+ """文字が絵文字の主要なUnicode範囲に含まれるか判定する。"""
22
+ if not char:
23
+ return False
24
+ code = ord(char)
25
+ return (
26
+ 127744 <= code <= 128511
27
+ or 128512 <= code <= 128591
28
+ or 128640 <= code <= 128767
29
+ or (129280 <= code <= 129535)
30
+ or (9728 <= code <= 9983)
31
+ or (9984 <= code <= 10175)
32
+ or (129648 <= code <= 129791)
33
+ )
34
+
35
+
36
+ _ESCAPABLE_INLINE_CHARS = set("\\`*_{}[]()#+-.!<>~")
37
+ _INLINE_DELIMITERS: tuple[tuple[str, dict[str, bool]], ...] = (
38
+ ("***", {"bold": True, "italic": True}),
39
+ ("___", {"bold": True, "italic": True}),
40
+ ("**", {"bold": True}),
41
+ ("__", {"bold": True}),
42
+ ("~~", {"strike": True}),
43
+ ("*", {"italic": True}),
44
+ ("_", {"italic": True}),
45
+ )
46
+ _CORE_URL_PATTERN = "https?:\\/\\/[\\w\\-\\.~:\\/?#[\\]@!$&\\'\\(\\)\\*\\+,;=%.]+"
47
+ _MARKDOWN_DESTINATION_PATTERN = "(?:<[^>\\n]+>|(?:[^\\s()<>]|\\([^\\s()<>]*\\))+)"
48
+ _MARKDOWN_DESTINATION_RE = re.compile(
49
+ f"\\(\\s*(?P<url>{_MARKDOWN_DESTINATION_PATTERN})\\s*\\)"
50
+ )
51
+ _URL_LINK_PATTERN = re.compile(
52
+ f"(?P<autolink><(?P<auto_url>{_CORE_URL_PATTERN})>)|(?P<plain_url>{_CORE_URL_PATTERN})"
53
+ )
54
+ _URL_START_PATTERN = re.compile(r"<?https?://")
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class _InlineLink:
59
+ start: int
60
+ end: int
61
+ kind: str
62
+ text: str
63
+ url: str
64
+ label: str | None = None
65
+
66
+
67
+ def _iter_links(text: str) -> Iterator[_InlineLink]:
68
+ """ラベルの括弧を一度だけ走査し、コードとエスケープを除外してリンクを返す。"""
69
+ code_ends: dict[int, int] = {}
70
+ offset = 0
71
+ for line in text.split("\n"):
72
+ code_ends.update(
73
+ (offset + start, offset + end)
74
+ for start, end, _ in _iter_inline_code_spans(line)
75
+ )
76
+ offset += len(line) + 1
77
+ links: list[_InlineLink] = []
78
+ opening: int | None = None
79
+ index = 0
80
+ while index < len(text):
81
+ if index in code_ends:
82
+ index = code_ends[index]
83
+ continue
84
+ if text[index] == "\\" and index + 1 < len(text):
85
+ index += 2
86
+ continue
87
+ if text[index] == "[":
88
+ opening = index
89
+ elif text[index] == "]":
90
+ if opening is not None:
91
+ destination = _MARKDOWN_DESTINATION_RE.match(text, index + 1)
92
+ if destination:
93
+ end = destination.end()
94
+ links.append(
95
+ _InlineLink(
96
+ opening,
97
+ end,
98
+ "markdown",
99
+ text[opening:end],
100
+ destination.group("url"),
101
+ text[opening + 1 : index],
102
+ )
103
+ )
104
+ index = end - 1
105
+ opening = None
106
+ index += 1
107
+ index = 0
108
+ cursor = 0
109
+ while cursor < len(text):
110
+ while index < len(links) and links[index].start < cursor:
111
+ index += 1
112
+ link = links[index] if index < len(links) else None
113
+ # Markdownの手前でURLの開始だけを探し、宛先内のURLを再走査しない。
114
+ boundary = link.start if link is not None else len(text)
115
+ candidate = _URL_START_PATTERN.search(text, cursor, boundary)
116
+ if candidate is None and link is not None:
117
+ yield link
118
+ cursor = link.end
119
+ index += 1
120
+ elif candidate is not None:
121
+ match = _URL_LINK_PATTERN.match(text, candidate.start())
122
+ if match is None:
123
+ cursor = candidate.start() + 1
124
+ continue
125
+ autolink = match.group("autolink") is not None
126
+ yield _InlineLink(
127
+ match.start(),
128
+ match.end(),
129
+ "autolink" if autolink else "plain",
130
+ match.group(),
131
+ match.group("auto_url" if autolink else "plain_url"),
132
+ )
133
+ cursor = match.end()
134
+ else:
135
+ break
136
+
137
+
138
+ def _transform_outside_inline_code(
139
+ text: str, transform: Callable[[str], str], *, normalize_html_code: bool = False
140
+ ) -> str:
141
+ """コードを一時的に保護し、その外側だけに文字列置換を適用する。"""
142
+ ranges = [
143
+ (start, end, text[start:end]) for start, end, _ in _iter_inline_code_spans(text)
144
+ ]
145
+ code_starts = [start for start, _, _ in ranges]
146
+
147
+ def code_end_at(position: int, minimum_start: int) -> int | None:
148
+ index = bisect_right(code_starts, position) - 1
149
+ # 先に処理したHTML code内のバッククォートは、後続のタグを保護しない。
150
+ if (
151
+ index >= 0
152
+ and ranges[index][0] >= minimum_start
153
+ and position < ranges[index][1]
154
+ ):
155
+ return ranges[index][1]
156
+ return None
157
+
158
+ opening_pattern = re.compile(r"<code\b", re.IGNORECASE)
159
+ closing_pattern = re.compile(r"</code>", re.IGNORECASE)
160
+ cursor = 0
161
+ while opening := opening_pattern.search(text, cursor):
162
+ if code_end := code_end_at(opening.start(), cursor):
163
+ cursor = code_end
164
+ continue
165
+ tag_end = text.find(">", opening.end())
166
+ if tag_end == -1:
167
+ break
168
+ closing = closing_pattern.search(text, tag_end + 1)
169
+ if closing is None:
170
+ break
171
+ replacement = text[opening.start() : closing.end()]
172
+ if normalize_html_code:
173
+ body = text[tag_end + 1 : closing.start()]
174
+ delimiter = "`" * (
175
+ max((len(run) for run in re.findall(r"`+", body)), default=0) + 1
176
+ )
177
+ replacement = f"{delimiter} {body} {delimiter}"
178
+ ranges.append((opening.start(), closing.end(), replacement))
179
+ cursor = closing.end()
180
+ if not ranges:
181
+ return transform(text)
182
+
183
+ prefix = "\x00code:"
184
+ while prefix in text:
185
+ prefix += ":"
186
+ fragments = []
187
+ protected = []
188
+ cursor = 0
189
+ for start, end, replacement in sorted(ranges):
190
+ if start < cursor:
191
+ continue
192
+ fragments.extend((text[cursor:start], f"{prefix}{len(protected)}\x00"))
193
+ protected.append(replacement)
194
+ cursor = end
195
+ fragments.append(text[cursor:])
196
+ transformed = transform("".join(fragments))
197
+ return re.sub(
198
+ re.escape(prefix) + r"(\d+)\x00",
199
+ lambda match: protected[int(match.group(1))],
200
+ transformed,
201
+ )
202
+
203
+
204
+ def _normalize_inline_html_lite(text: str) -> str:
205
+ """対応するインラインHTMLタグをMarkdown記法に置き換える。"""
206
+ if not text or "<" not in text:
207
+ return text
208
+ tag_replacements: tuple[tuple[str, str, str], ...] = (
209
+ ("strong", "**", "**"),
210
+ ("b", "**", "**"),
211
+ ("em", "*", "*"),
212
+ ("i", "*", "*"),
213
+ ("s", "~~", "~~"),
214
+ ("del", "~~", "~~"),
215
+ )
216
+
217
+ def replace_tags(source: str) -> str:
218
+ for tag, opener, closer in tag_replacements:
219
+ pattern = f"<{tag}\\b[^>]*>([\\s\\S]*?)</{tag}>"
220
+ source = re.sub(
221
+ pattern,
222
+ lambda match, prefix=opener, suffix=closer: (
223
+ f"{prefix}{match.group(1)}{suffix}"
224
+ ),
225
+ source,
226
+ flags=re.IGNORECASE,
227
+ )
228
+ return source
229
+
230
+ return _transform_outside_inline_code(text, replace_tags, normalize_html_code=True)
231
+
232
+
233
+ def _is_escaped(text: str, index: int) -> bool:
234
+ """index位置の文字が、直前の奇数個のバックスラッシュでエスケープされているか判定する。"""
235
+ slash_count = 0
236
+ cursor = index - 1
237
+ while cursor >= 0 and text[cursor] == "\\":
238
+ slash_count += 1
239
+ cursor -= 1
240
+ return slash_count % 2 == 1
241
+
242
+
243
+ def _can_open_inline_delimiter(text: str, index: int, delimiter: str) -> bool:
244
+ after_index = index + len(delimiter)
245
+ if after_index >= len(text) or text[after_index].isspace():
246
+ return False
247
+ if _is_escaped(text, index):
248
+ return False
249
+ if delimiter.startswith("_"):
250
+ before = text[index - 1] if index > 0 else ""
251
+ after = text[after_index]
252
+ if before.isalnum() and after.isalnum():
253
+ return False
254
+ return True
255
+
256
+
257
+ def _can_close_inline_delimiter(text: str, index: int, delimiter: str) -> bool:
258
+ before = text[index - 1] if index > 0 else ""
259
+ if not before or before.isspace():
260
+ return False
261
+ if _is_escaped(text, index):
262
+ return False
263
+ if delimiter.startswith("_"):
264
+ after_index = index + len(delimiter)
265
+ after = text[after_index] if after_index < len(text) else ""
266
+ if before.isalnum() and after.isalnum():
267
+ return False
268
+ return True
269
+
270
+
271
+ def _find_closing_inline_delimiter(
272
+ text: str,
273
+ start: int,
274
+ delimiter: str,
275
+ code_ranges: list[tuple[int, int]] | None = None,
276
+ *,
277
+ cache: dict[str, list[int]] | None = None,
278
+ ) -> int | None:
279
+ """同じ本文・保護範囲の閉じ記号を共有し、未閉じ記号ごとの再探索を避ける。"""
280
+ if cache is None:
281
+ cache = {}
282
+ if delimiter not in cache:
283
+ positions: list[int] = []
284
+ protected = sorted(code_ranges or [])
285
+ range_index = 0
286
+ cursor = 0
287
+ while cursor < len(text):
288
+ found_at = text.find(delimiter, cursor)
289
+ if found_at == -1:
290
+ break
291
+ while (
292
+ range_index < len(protected) and protected[range_index][1] <= found_at
293
+ ):
294
+ range_index += 1
295
+ inside_protected = (
296
+ range_index < len(protected) and protected[range_index][0] <= found_at
297
+ )
298
+ if not inside_protected and _can_close_inline_delimiter(
299
+ text, found_at, delimiter
300
+ ):
301
+ positions.append(found_at)
302
+ cursor = found_at + 1
303
+ cache[delimiter] = positions
304
+ positions = cache[delimiter]
305
+ position_index = bisect_left(positions, start)
306
+ return positions[position_index] if position_index < len(positions) else None
307
+
308
+
309
+ def _span_for_text(text: str, style: dict[str, bool]) -> dict | None:
310
+ if text == "":
311
+ return None
312
+ span = {"type": "span", "text": text, "color": BODY_TEXT_COLOR, "size": "sm"}
313
+ if style.get("bold"):
314
+ span["weight"] = "bold"
315
+ span["color"] = STRONG_TEXT_COLOR
316
+ if style.get("italic"):
317
+ span["style"] = "italic"
318
+ if style.get("strike"):
319
+ span["decoration"] = "line-through"
320
+ if style.get("code"):
321
+ span["color"] = INLINE_CODE_COLOR
322
+ return span
323
+
324
+
325
+ def _merge_adjacent_spans(spans: list[dict]) -> list[dict]:
326
+ merged: list[dict] = []
327
+ for span in spans:
328
+ if not span or not span.get("text"):
329
+ continue
330
+ if merged:
331
+ previous = merged[-1]
332
+ previous_key = {k: v for k, v in previous.items() if k != "text"}
333
+ current_key = {k: v for k, v in span.items() if k != "text"}
334
+ if previous_key == current_key:
335
+ previous["text"] += span["text"]
336
+ continue
337
+ merged.append(span)
338
+ return merged
339
+
340
+
341
+ def _iter_inline_code_spans(text: str) -> list[tuple[int, int, str]]:
342
+ """インラインコードを (開始位置, 終了位置, 正規化した本文) のタプルで列挙する。
343
+
344
+ 終了位置は閉じるバッククォートの直後。本文の改行と連続する空白を半角スペース1つにまとめる。
345
+ 強調の解析でもこの範囲を使い、**`code`** のような記法を処理する。
346
+ """
347
+ spans: list[tuple[int, int, str]] = []
348
+ index = 0
349
+ while index < len(text):
350
+ if text[index] != "`" or _is_escaped(text, index):
351
+ index += 1
352
+ continue
353
+ run_end = index
354
+ while run_end < len(text) and text[run_end] == "`":
355
+ run_end += 1
356
+ delimiter = text[index:run_end]
357
+ closing = run_end
358
+ closing_index: int | None = None
359
+ while closing < len(text):
360
+ candidate = text.find(delimiter, closing)
361
+ if candidate == -1:
362
+ break
363
+ if not _is_escaped(text, candidate):
364
+ closing_index = candidate
365
+ break
366
+ closing = candidate + 1
367
+ if closing_index is None:
368
+ index = run_end
369
+ continue
370
+ code_text = text[run_end:closing_index]
371
+ code_text = re.sub("\\s+", " ", code_text.replace("\n", " ")).strip(" ")
372
+ end = closing_index + len(delimiter)
373
+ spans.append((index, end, code_text))
374
+ index = end
375
+ return spans
376
+
377
+
378
+ def _inline_code_span_ranges(text: str) -> list[tuple[int, int]]:
379
+ """インラインコードの範囲を、バッククォートも含めて返す。"""
380
+ return [(start, end) for start, end, _ in _iter_inline_code_spans(text)]
381
+
382
+
383
+ def _position_inside_ranges(position: int, ranges: list[tuple[int, int]]) -> bool:
384
+ """開始位置順で重複のない範囲から、指定位置を二分探索する。"""
385
+ index = bisect_right(ranges, position, key=lambda interval: interval[0]) - 1
386
+ return index >= 0 and position < ranges[index][1]
387
+
388
+
389
+ def _find_unclosed_inline_delimiter(text: str) -> tuple[str, int] | None:
390
+ code_ranges = _inline_code_span_ranges(text)
391
+ closing_cache: dict[str, list[int]] = {}
392
+ index = 0
393
+ while index < len(text):
394
+ if _position_inside_ranges(index, code_ranges):
395
+ index += 1
396
+ continue
397
+ matched = False
398
+ for delimiter, _style_update in _INLINE_DELIMITERS:
399
+ if not text.startswith(delimiter, index):
400
+ continue
401
+ if not _can_open_inline_delimiter(text, index, delimiter):
402
+ continue
403
+ inner_start = index + len(delimiter)
404
+ closing_index = _find_closing_inline_delimiter(
405
+ text, inner_start, delimiter, code_ranges, cache=closing_cache
406
+ )
407
+ if closing_index is None:
408
+ return (delimiter, index)
409
+ index = closing_index + len(delimiter)
410
+ matched = True
411
+ break
412
+ if not matched:
413
+ index += 1
414
+ return None
415
+
416
+
417
+ def _inline_link_ranges(
418
+ text: str, code_ranges: list[tuple[int, int]]
419
+ ) -> list[tuple[int, int]]:
420
+ """リンクの範囲を返す。URL内の記号を外側の強調と混同しないために使う。"""
421
+ ranges: list[tuple[int, int]] = []
422
+ prefix = ""
423
+ previous_end = 0
424
+ for link in _iter_links(text):
425
+ start, end = link.start, link.end
426
+ if _position_inside_ranges(start, code_ranges):
427
+ continue
428
+ prefix += text[previous_end:start]
429
+ if link.kind == "plain":
430
+ url = _strip_plain_url_punctuation(link.url)
431
+ opener = _find_unclosed_inline_delimiter(prefix + "x")
432
+ if opener and url.endswith(opener[0]):
433
+ url = url[: -len(opener[0])]
434
+ end = start + len(url)
435
+ ranges.append((start, end))
436
+ prefix += "x"
437
+ previous_end = end
438
+ return ranges
439
+
440
+
441
+ def _strip_plain_url_punctuation(url: str) -> str:
442
+ while url and url[-1] in ".,;:!?)>":
443
+ if (
444
+ url[-1] == ")"
445
+ and "(" in url[:-1]
446
+ and (url[:-1].count("(") >= url.count(")"))
447
+ ):
448
+ break
449
+ url = url[:-1]
450
+ return url
451
+
452
+
453
+ def _parse_inline_styled_spans(
454
+ text: str, style: dict[str, bool], *, parse_links: bool = False, _depth: int = 0
455
+ ) -> list[dict]:
456
+ """インラインのMarkdownを解析し、リンクの前後にも同じ強調を適用する。
457
+
458
+ インラインコードを優先して処理し、その範囲を保持して強調の開始・終了記号を対応付ける。
459
+ これにより **`code`** のような記法を扱う。コード内のMarkdownは装飾として解釈しない。
460
+ parse_linksが有効な場合、_linkを内部トークンとして使う。
461
+ アクション行への分離は強調範囲の確定後に行う。
462
+ """
463
+ if _depth >= 64:
464
+ span = _span_for_text(text, style)
465
+ return [span] if span else []
466
+ spans: list[dict] = []
467
+ buffer: list[str] = []
468
+ index = 0
469
+ code_spans = _iter_inline_code_spans(text)
470
+ code_span_by_start = {
471
+ start: (end, code_text) for start, end, code_text in code_spans
472
+ }
473
+ code_ranges = [(start, end) for start, end, _ in code_spans]
474
+ link_ranges = _inline_link_ranges(text, code_ranges) if parse_links else []
475
+ link_end_by_start = dict(link_ranges)
476
+ protected_ranges = code_ranges + link_ranges
477
+ closing_cache: dict[str, list[int]] = {}
478
+
479
+ def flush_text() -> None:
480
+ if buffer:
481
+ span = _span_for_text("".join(buffer), style)
482
+ if span:
483
+ spans.append(span)
484
+ buffer.clear()
485
+
486
+ while index < len(text):
487
+ char = text[index]
488
+ if (
489
+ char == "\\"
490
+ and index + 1 < len(text)
491
+ and (text[index + 1] in _ESCAPABLE_INLINE_CHARS)
492
+ ):
493
+ buffer.append(text[index + 1])
494
+ index += 2
495
+ continue
496
+ if index in code_span_by_start:
497
+ end, code_text = code_span_by_start[index]
498
+ flush_text()
499
+ code_style = dict(style)
500
+ code_style["code"] = True
501
+ span = _span_for_text(code_text, code_style)
502
+ if span:
503
+ spans.append(span)
504
+ index = end
505
+ continue
506
+ if index in link_end_by_start:
507
+ end = link_end_by_start[index]
508
+ flush_text()
509
+ spans.append(
510
+ {"type": "_link", "text": text[index:end], "style": dict(style)}
511
+ )
512
+ index = end
513
+ continue
514
+ matched = False
515
+ for delimiter, style_update in _INLINE_DELIMITERS:
516
+ if not text.startswith(delimiter, index):
517
+ continue
518
+ if not _can_open_inline_delimiter(text, index, delimiter):
519
+ continue
520
+ inner_start = index + len(delimiter)
521
+ closing_index = _find_closing_inline_delimiter(
522
+ text, inner_start, delimiter, protected_ranges, cache=closing_cache
523
+ )
524
+ if closing_index is None:
525
+ continue
526
+ inner_text = text[inner_start:closing_index]
527
+ if not inner_text.strip():
528
+ continue
529
+ flush_text()
530
+ child_style = dict(style)
531
+ child_style.update(style_update)
532
+ spans.extend(
533
+ _parse_inline_styled_spans(
534
+ inner_text, child_style, parse_links=parse_links, _depth=_depth + 1
535
+ )
536
+ )
537
+ index = closing_index + len(delimiter)
538
+ matched = True
539
+ break
540
+ if matched:
541
+ continue
542
+ if char in "*_":
543
+ run_end = index
544
+ while run_end < len(text) and text[run_end] == char:
545
+ run_end += 1
546
+ before = text[index - 1] if index else ""
547
+ after = text[run_end] if run_end < len(text) else ""
548
+ if run_end - index in (2, 3) and (
549
+ not (before.isalnum() and after.isalnum())
550
+ ):
551
+ index = run_end
552
+ continue
553
+ buffer.append(char)
554
+ index += 1
555
+ flush_text()
556
+ return spans
557
+
558
+
559
+ def _parse_inline_to_spans(
560
+ text: str, base_style: dict[str, bool] | None = None, *, normalize_html: bool = True
561
+ ) -> list[dict]:
562
+ if text == "":
563
+ return []
564
+ style = dict(base_style or {})
565
+ source = _normalize_inline_html_lite(text) if normalize_html else text
566
+ spans = _parse_inline_styled_spans(source, style)
567
+ return _merge_adjacent_spans(spans)
568
+
569
+
570
+ def process_text_segment_to_spans(
571
+ text_segment, base_style: dict[str, bool] | None = None
572
+ ):
573
+ """テキストを、Markdownのインライン装飾を反映したFlex spanに変換する。"""
574
+ return _parse_inline_to_spans(text_segment, base_style)
575
+
576
+
577
+ def process_line_to_flex_components(line):
578
+ """強調範囲を確定してから、通常テキストと URI アクション行へ分離する。"""
579
+ tokens = _parse_inline_styled_spans(
580
+ _normalize_inline_html_lite(line), {}, parse_links=True
581
+ )
582
+ components: list[dict] = []
583
+ pending_spans: list[dict] = []
584
+ after_link = False
585
+
586
+ def flush_text(*, before_link: bool) -> None:
587
+ spans = _merge_adjacent_spans(pending_spans)
588
+ pending_spans.clear()
589
+ if after_link:
590
+ while spans:
591
+ spans[0]["text"] = spans[0]["text"].lstrip(" \t")
592
+ if spans[0]["text"]:
593
+ break
594
+ spans.pop(0)
595
+ if before_link:
596
+ while spans:
597
+ spans[-1]["text"] = spans[-1]["text"].rstrip(" \t")
598
+ if spans[-1]["text"]:
599
+ break
600
+ spans.pop()
601
+ visible = "".join(span["text"] for span in spans)
602
+ if visible.strip() and (
603
+ not (before_link or after_link)
604
+ or visible.strip("。、,・//|| \t\n")
605
+ or any(span.get("color") == INLINE_CODE_COLOR for span in spans)
606
+ ):
607
+ components.append(
608
+ {
609
+ "type": "text",
610
+ "contents": spans,
611
+ "wrap": True,
612
+ "margin": "none",
613
+ "size": "sm",
614
+ }
615
+ )
616
+
617
+ for token in tokens:
618
+ if token["type"] == "_link":
619
+ flush_text(before_link=True)
620
+ components.extend(
621
+ _process_line_to_flex_components_without_code_spans(
622
+ token["text"], token["style"]
623
+ )
624
+ )
625
+ after_link = True
626
+ else:
627
+ pending_spans.append(token)
628
+ flush_text(before_link=False)
629
+ return components
630
+
631
+
632
+ def _clone_text_component_with_spans(
633
+ component: dict[str, Any], spans: list[dict]
634
+ ) -> dict[str, Any] | None:
635
+ merged_spans = _merge_adjacent_spans(spans)
636
+ if not merged_spans:
637
+ return None
638
+ cloned = {key: value for key, value in component.items() if key != "contents"}
639
+ cloned["contents"] = merged_spans
640
+ return cloned
641
+
642
+
643
+ def _split_text_component_on_newlines(
644
+ component: dict[str, Any],
645
+ ) -> list[dict[str, Any]]:
646
+ if component.get("type") != "text" or not isinstance(
647
+ component.get("contents"), list
648
+ ):
649
+ return [component]
650
+ split_components: list[dict[str, Any]] = []
651
+ current_spans: list[dict] = []
652
+ saw_newline = False
653
+ for span in component.get("contents", []):
654
+ span_text = span.get("text")
655
+ if not isinstance(span_text, str) or "\n" not in span_text:
656
+ current_spans.append(span)
657
+ continue
658
+ saw_newline = True
659
+ parts = span_text.split("\n")
660
+ for part_index, part in enumerate(parts):
661
+ if part:
662
+ split_span = dict(span)
663
+ split_span["text"] = part
664
+ current_spans.append(split_span)
665
+ if part_index < len(parts) - 1:
666
+ split_component = _clone_text_component_with_spans(
667
+ component, current_spans
668
+ )
669
+ if split_component:
670
+ split_components.append(split_component)
671
+ current_spans = []
672
+ if not saw_newline:
673
+ return [component]
674
+ split_component = _clone_text_component_with_spans(component, current_spans)
675
+ if split_component:
676
+ split_components.append(split_component)
677
+ return split_components
678
+
679
+
680
+ def _normalize_link_adjacent_text_segment(
681
+ segment: str, *, trim_start: bool, trim_end: bool
682
+ ) -> str:
683
+ """Drop separator spaces that only existed around an inline link in Markdown."""
684
+ if trim_start:
685
+ segment = segment.lstrip(" \t")
686
+ if trim_end:
687
+ segment = segment.rstrip(" \t")
688
+ return segment
689
+
690
+
691
+ def _process_line_to_flex_components_without_code_spans(
692
+ line, base_style: dict[str, bool] | None = None
693
+ ):
694
+ """インラインコードの外側だけをリンク化し、通常テキストは装飾つきのFlex spanに変換する。"""
695
+ line_components = []
696
+ inline_code_ranges = _inline_code_span_ranges(line)
697
+ last_index = 0
698
+ for link in _iter_links(line):
699
+ match_start, match_end = link.start, link.end
700
+ if _position_inside_ranges(match_start, inline_code_ranges):
701
+ continue
702
+ if match_start > last_index:
703
+ segment = _normalize_link_adjacent_text_segment(
704
+ line[last_index:match_start], trim_start=last_index > 0, trim_end=True
705
+ )
706
+ if segment.strip("。、,・//|| "):
707
+ spans = process_text_segment_to_spans(segment, base_style)
708
+ if spans:
709
+ line_components.append(
710
+ {
711
+ "type": "text",
712
+ "contents": spans,
713
+ "wrap": True,
714
+ "margin": "none",
715
+ "size": "sm",
716
+ }
717
+ )
718
+ action_uri = None
719
+ markdown_text_raw = None
720
+ link_consumed_end = match_end
721
+ if link.kind == "markdown":
722
+ markdown_text_raw = link.label
723
+ action_uri = link.url
724
+ if action_uri and action_uri.startswith("<") and action_uri.endswith(">"):
725
+ action_uri = action_uri[1:-1].strip()
726
+ if not action_uri:
727
+ last_index = match_end
728
+ continue
729
+ elif link.kind == "autolink":
730
+ markdown_text_raw = None
731
+ action_uri = link.url
732
+ elif link.kind == "plain":
733
+ markdown_text_raw = None
734
+ original_plain_url = link.url
735
+ action_uri = _strip_plain_url_punctuation(original_plain_url)
736
+ if not action_uri:
737
+ last_index = match_end
738
+ continue
739
+ link_consumed_end = match_start + len(action_uri)
740
+ else:
741
+ last_index = match_end
742
+ continue
743
+ link_component = None
744
+ if action_uri:
745
+ action_uri = action_uri.strip()
746
+ if not action_uri.startswith(("http://", "https://")):
747
+ # Unsupported destinations never become actions. Preserve the
748
+ # useful label without exposing a local or custom-scheme URI.
749
+ segment = markdown_text_raw or link.text
750
+ spans = process_text_segment_to_spans(segment, base_style)
751
+ if spans:
752
+ line_components.append(
753
+ {
754
+ "type": "text",
755
+ "contents": spans,
756
+ "wrap": True,
757
+ "margin": "none",
758
+ "size": "sm",
759
+ }
760
+ )
761
+ last_index = match_end
762
+ continue
763
+ try:
764
+ parsed_uri = urllib.parse.urlsplit(action_uri)
765
+ if not parsed_uri.scheme or not parsed_uri.hostname:
766
+ raise ValueError("Invalid URL structure (missing scheme or netloc)")
767
+ valid_hostname_pattern = "^[a-zA-Z0-9\\.\\-:]+$"
768
+ if not re.fullmatch(valid_hostname_pattern, parsed_uri.netloc):
769
+ raise ValueError(
770
+ f"Invalid hostname characters in netloc: {parsed_uri.netloc}"
771
+ )
772
+ # Accessing port validates both its numeric form and range.
773
+ _ = parsed_uri.port
774
+ if re.search(r"%(?![0-9A-Fa-f]{2})", action_uri):
775
+ raise ValueError("Invalid percent escape")
776
+ # RFC 3986 pchar, plus the separators allowed in each component.
777
+ path_safe = "/:@!$&'()*+,;=%"
778
+ sanitized_path = urllib.parse.quote(parsed_uri.path, safe=path_safe)
779
+ sanitized_query = urllib.parse.quote(
780
+ parsed_uri.query, safe=path_safe + "?"
781
+ )
782
+ sanitized_fragment = urllib.parse.quote(
783
+ parsed_uri.fragment, safe=path_safe + "?"
784
+ )
785
+ sanitized_uri = (
786
+ f"{parsed_uri.scheme}://{parsed_uri.netloc}{sanitized_path}"
787
+ )
788
+ if "?" in action_uri.partition("#")[0]:
789
+ sanitized_uri += f"?{sanitized_query}"
790
+ if "#" in action_uri:
791
+ sanitized_uri += f"#{sanitized_fragment}"
792
+ action_uri = sanitized_uri
793
+ except ValueError:
794
+ segment = link.text
795
+ spans = process_text_segment_to_spans(segment, base_style)
796
+ if spans:
797
+ line_components.append(
798
+ {
799
+ "type": "text",
800
+ "contents": spans,
801
+ "wrap": True,
802
+ "margin": "none",
803
+ "size": "sm",
804
+ }
805
+ )
806
+ last_index = match_end
807
+ continue
808
+ if len(action_uri) > LINE_ACTION_URI_MAX_LENGTH:
809
+ segment = link.text
810
+ spans = process_text_segment_to_spans(segment, base_style)
811
+ if spans:
812
+ line_components.append(
813
+ {
814
+ "type": "text",
815
+ "contents": spans,
816
+ "wrap": True,
817
+ "margin": "none",
818
+ "size": "sm",
819
+ }
820
+ )
821
+ last_index = match_end
822
+ continue
823
+ is_markdown_link = link.kind == "markdown"
824
+ is_autolink = link.kind == "autolink"
825
+ display_text_source = ""
826
+ if is_markdown_link:
827
+ display_text_source = (
828
+ markdown_text_raw.strip()
829
+ if markdown_text_raw and markdown_text_raw.strip()
830
+ else action_uri
831
+ )
832
+ elif is_autolink:
833
+ display_text_source = action_uri
834
+ else:
835
+ display_text_source = action_uri
836
+ display_spans = (
837
+ process_text_segment_to_spans(display_text_source, base_style)
838
+ if is_markdown_link and markdown_text_raw and markdown_text_raw.strip()
839
+ else [_span_for_text(action_uri, base_style or {})]
840
+ )
841
+ total_text_length = sum(len(span.get("text", "")) for span in display_spans)
842
+ if total_text_length > 40:
843
+ remaining = 37
844
+ shortened = []
845
+ for span in display_spans:
846
+ if remaining <= 0:
847
+ break
848
+ clipped = dict(span)
849
+ clipped["text"] = span["text"][:remaining]
850
+ remaining -= len(clipped["text"])
851
+ shortened.append(clipped)
852
+ shortened[-1]["text"] += "..."
853
+ display_spans = shortened
854
+ link_icon_span = {
855
+ "type": "span",
856
+ "text": "🔗 ",
857
+ "color": LINK_TEXT_COLOR,
858
+ "size": "sm",
859
+ "decoration": "underline",
860
+ }
861
+ final_spans = [link_icon_span]
862
+ for span in display_spans:
863
+ span["color"] = LINK_TEXT_COLOR
864
+ span["decoration"] = "underline"
865
+ final_spans.append(span)
866
+ link_component = {
867
+ "type": "text",
868
+ "contents": final_spans,
869
+ "wrap": True,
870
+ "margin": "sm",
871
+ "action": {"type": "uri", "uri": action_uri},
872
+ }
873
+ if link_component:
874
+ line_components.append(link_component)
875
+ last_index = link_consumed_end
876
+ if last_index < len(line):
877
+ segment = _normalize_link_adjacent_text_segment(
878
+ line[last_index:], trim_start=last_index > 0, trim_end=False
879
+ )
880
+ if segment and segment.strip("。、,・//|| "):
881
+ spans = process_text_segment_to_spans(segment, base_style)
882
+ if spans:
883
+ line_components.append(
884
+ {
885
+ "type": "text",
886
+ "contents": spans,
887
+ "wrap": True,
888
+ "margin": "none",
889
+ "size": "sm",
890
+ }
891
+ )
892
+ if not line_components and last_index == 0 and line.strip():
893
+ if line.strip().strip("。、,・//|| "):
894
+ spans = process_text_segment_to_spans(line.strip(), base_style)
895
+ if spans:
896
+ line_components.append(
897
+ {
898
+ "type": "text",
899
+ "contents": spans,
900
+ "wrap": True,
901
+ "margin": "none",
902
+ "size": "sm",
903
+ }
904
+ )
905
+ return line_components
906
+
907
+
908
+ _MARKDOWN_HARD_BREAK_RE = re.compile("( {2,}|<br\\s*/?>)\\s*$", flags=re.IGNORECASE)
909
+ _UNORDERED_LIST_MATCH_RE = re.compile(
910
+ "^(\\s*)(?:(?:[-*+‐•]\\s+)|(?:・\\s*))([\\s\\S]+)$"
911
+ )
912
+ _LIST_ITEM_MARKER_RE = re.compile(
913
+ "^\\s*(?:(?:[-*+‐•]\\s+)|(?:・\\s*)|\\d+[.)]\\s+)(?P<text>.*)$"
914
+ )
915
+ _LIST_ITEM_TITLE_CONTENT_RE = re.compile("^(\\*{1,3})(?!\\s).+?(?<!\\s)\\1(?:[::])?$")
916
+ _TABLE_BLOCK_TERMINATOR_RE = re.compile(
917
+ "^\\s*(?:#{1,6}\\s+|(?:[-*+‐•]\\s+|・\\s*)|\\d+[.)]\\s+|>)"
918
+ )
919
+
920
+
921
+ def _ends_with_markdown_hard_break(text: str) -> bool:
922
+ """末尾行が Markdown のハード改行マーカー(末尾2スペース / <br>)で終わるか判定する。"""
923
+ last_line = text.split("\n")[-1]
924
+ return bool(_MARKDOWN_HARD_BREAK_RE.search(last_line))
925
+
926
+
927
+ def _strip_markdown_hard_break(text: str) -> str:
928
+ """末尾行の Markdown ハード改行マーカー(末尾2スペース / <br>)を除去する。"""
929
+ lines = text.split("\n")
930
+ if not lines:
931
+ return text
932
+ lines[-1] = _MARKDOWN_HARD_BREAK_RE.sub("", lines[-1])
933
+ return "\n".join(lines)
934
+
935
+
936
+ def _looks_like_list_item_title_line(text: str) -> bool:
937
+ """リストの末尾行がタイトルとみなせる場合にTrueを返す(改行として扱う)。"""
938
+ last_line = text.split("\n")[-1].rstrip()
939
+ match = _LIST_ITEM_MARKER_RE.match(last_line)
940
+ if not match:
941
+ return False
942
+ item_text = (match.group("text") or "").strip()
943
+ if not item_text:
944
+ return False
945
+ if item_text.endswith((":", ":")):
946
+ return True
947
+ return bool(_LIST_ITEM_TITLE_CONTENT_RE.fullmatch(item_text))
948
+
949
+
950
+ def _allows_multiline_inline_continuation(line: str) -> bool:
951
+ stripped = line.strip()
952
+ if not stripped:
953
+ return False
954
+ if stripped.startswith(">"):
955
+ return False
956
+ if re.match("^(#{1,6})\\s+(.*)", stripped):
957
+ return False
958
+ if re.match("^(\\s*)(?:[-‐]{3,}|\\*{3,}|_{3,})\\s*$", stripped):
959
+ return False
960
+ if _UNORDERED_LIST_MATCH_RE.match(line):
961
+ return False
962
+ if re.match("^(\\s*)(\\d+)([.)])\\s+(.*)", line):
963
+ return False
964
+ if re.match("^\\s+.+", line):
965
+ return False
966
+ return True
967
+
968
+
969
+ def _collect_multiline_inline_block(
970
+ lines: list[str], start_index: int, closing_line_cache: dict[str, list[int | None]]
971
+ ) -> tuple[str, int] | None:
972
+ start_line = lines[start_index].strip()
973
+ opener = _find_unclosed_inline_delimiter(start_line)
974
+ if opener is None:
975
+ return None
976
+ delimiter, opener_index = opener
977
+ if (
978
+ _find_closing_inline_delimiter(
979
+ start_line, opener_index + len(delimiter), delimiter
980
+ )
981
+ is not None
982
+ ):
983
+ return None
984
+ if delimiter not in closing_line_cache:
985
+ closing_lines: list[int | None] = [None] * len(lines)
986
+ next_closing_line: int | None = None
987
+ for index in range(len(lines) - 1, -1, -1):
988
+ closing_lines[index] = next_closing_line
989
+ line = lines[index]
990
+ if not _allows_multiline_inline_continuation(line):
991
+ next_closing_line = None
992
+ elif _find_closing_inline_delimiter(line.strip(), 0, delimiter) is not None:
993
+ next_closing_line = index
994
+ closing_line_cache[delimiter] = closing_lines
995
+ closing_line = closing_line_cache[delimiter][start_index]
996
+ if closing_line is None:
997
+ return None
998
+ joined = "\n".join(line.strip() for line in lines[start_index : closing_line + 1])
999
+ return (joined, closing_line + 1)
1000
+
1001
+
1002
+ def process_text_block_to_flex_components(text_block: str) -> list[dict[str, Any]]:
1003
+ """複数行を含むテキストを Flex コンポーネントのフラットなリストへ変換する。"""
1004
+ components: list[dict[str, Any]] = []
1005
+ normalized_lines = [
1006
+ raw_line.strip() for raw_line in text_block.splitlines() if raw_line.strip()
1007
+ ]
1008
+ if not normalized_lines:
1009
+ return components
1010
+ line_components = process_line_to_flex_components("\n".join(normalized_lines))
1011
+ for component in line_components:
1012
+ components.extend(_split_text_component_on_newlines(component))
1013
+ return components
1014
+
1015
+
1016
+ def _paragraph_box(
1017
+ components: list[dict[str, Any]], *, margin: str = "md"
1018
+ ) -> dict[str, Any] | None:
1019
+ non_empty = [component for component in components if component]
1020
+ if not non_empty:
1021
+ return None
1022
+ return {
1023
+ "type": "box",
1024
+ "layout": "vertical",
1025
+ "spacing": "xs",
1026
+ "contents": non_empty,
1027
+ "margin": margin,
1028
+ }
1029
+
1030
+
1031
+ def _spacer_component() -> dict[str, Any]:
1032
+ return {
1033
+ "type": "text",
1034
+ "text": " ",
1035
+ "size": "xxs",
1036
+ "margin": "none",
1037
+ "color": "#FFFFFF",
1038
+ }
1039
+
1040
+
1041
+ def _preserve_code_block_indentation(code: str) -> str:
1042
+ """Keep leading indentation visible in LINE clients that collapse ASCII spaces."""
1043
+ preserved_lines: list[str] = []
1044
+ for line in code.split("\n"):
1045
+ expanded_line = line.expandtabs(4)
1046
+ stripped_line = expanded_line.lstrip(" ")
1047
+ leading_space_count = len(expanded_line) - len(stripped_line)
1048
+ preserved_lines.append(
1049
+ CODE_BLOCK_INDENT_SPACE * leading_space_count + stripped_line
1050
+ )
1051
+ return "\n".join(preserved_lines)
1052
+
1053
+
1054
+ def _render_code_block(raw_code: str) -> dict[str, Any] | None:
1055
+ code_content = raw_code[3:-3]
1056
+ language = ""
1057
+ body = code_content
1058
+ if "\n" in code_content:
1059
+ first_line, rest = code_content.split("\n", 1)
1060
+ candidate_language = first_line.strip()
1061
+ if not candidate_language:
1062
+ body = rest.rstrip("\n")
1063
+ elif re.fullmatch("[A-Za-z0-9_+#.\\-]+", candidate_language):
1064
+ language = candidate_language
1065
+ body = rest.rstrip("\n")
1066
+ if not language and (not body.strip()):
1067
+ return None
1068
+ block_contents: list[dict[str, Any]] = []
1069
+ if language:
1070
+ block_contents.append(
1071
+ {
1072
+ "type": "text",
1073
+ "text": language,
1074
+ "wrap": True,
1075
+ "color": MUTED_TEXT_COLOR,
1076
+ "size": "xxs",
1077
+ "weight": "bold",
1078
+ "margin": "none",
1079
+ }
1080
+ )
1081
+ if body.strip():
1082
+ block_contents.append(
1083
+ {
1084
+ "type": "text",
1085
+ "text": _preserve_code_block_indentation(body),
1086
+ "wrap": True,
1087
+ "color": "#333333",
1088
+ "size": "sm",
1089
+ "margin": "xs" if language else "none",
1090
+ }
1091
+ )
1092
+ return {
1093
+ "type": "box",
1094
+ "layout": "vertical",
1095
+ "contents": block_contents,
1096
+ "backgroundColor": CODE_BLOCK_BACKGROUND,
1097
+ "paddingAll": "10px",
1098
+ "cornerRadius": "md",
1099
+ "margin": "md",
1100
+ }
1101
+
1102
+
1103
+ def _render_heading(heading_text: str, level: int) -> dict[str, Any] | None:
1104
+ spans = process_text_segment_to_spans(heading_text, {"bold": True})
1105
+ if not spans:
1106
+ return None
1107
+ size = "xxl" if level == 1 else "xl" if level == 2 else "lg"
1108
+ for span in spans:
1109
+ span["weight"] = "bold"
1110
+ span["size"] = size
1111
+ if not span.get("code"):
1112
+ span["color"] = "#444444"
1113
+ return {
1114
+ "type": "text",
1115
+ "contents": spans,
1116
+ "wrap": True,
1117
+ "weight": "bold",
1118
+ "size": size,
1119
+ "margin": "xs",
1120
+ }
1121
+
1122
+
1123
+ def _render_separator() -> dict[str, Any]:
1124
+ return {
1125
+ "type": "text",
1126
+ "text": "───────────────────────",
1127
+ "wrap": True,
1128
+ "color": "#CCCCCC",
1129
+ "size": "sm",
1130
+ "align": "center",
1131
+ "margin": "none",
1132
+ }
1133
+
1134
+
1135
+ def _render_quote_block(quote_text: str) -> dict[str, Any] | None:
1136
+ inner_components = process_text_block_to_flex_components(quote_text)
1137
+ if not inner_components:
1138
+ return None
1139
+ return {
1140
+ "type": "box",
1141
+ "layout": "horizontal",
1142
+ "contents": [
1143
+ {
1144
+ "type": "box",
1145
+ "layout": "vertical",
1146
+ "contents": [
1147
+ {"type": "text", "text": " ", "size": "xxs", "color": "#FFB6C1"}
1148
+ ],
1149
+ "width": "4px",
1150
+ "backgroundColor": "#FFB6C1",
1151
+ "cornerRadius": "2px",
1152
+ },
1153
+ {
1154
+ "type": "box",
1155
+ "layout": "vertical",
1156
+ "contents": inner_components,
1157
+ "spacing": "xs",
1158
+ "paddingStart": "10px",
1159
+ "flex": 1,
1160
+ },
1161
+ ],
1162
+ "backgroundColor": "#FFF7FA",
1163
+ "paddingAll": "8px",
1164
+ "cornerRadius": "md",
1165
+ "margin": "md",
1166
+ }
1167
+
1168
+
1169
+ def _is_markdown_table_separator(line: str) -> bool:
1170
+ stripped = line.strip()
1171
+ if "|" not in stripped:
1172
+ return False
1173
+ cells = [cell.strip() for cell in stripped.strip("|").split("|")]
1174
+ if len(cells) < 2:
1175
+ return False
1176
+ return all(re.fullmatch(":?-{3,}:?", cell or "") for cell in cells)
1177
+
1178
+
1179
+ def _looks_like_markdown_table_start(lines: list[str], index: int) -> bool:
1180
+ if index + 1 >= len(lines):
1181
+ return False
1182
+ header = lines[index].strip()
1183
+ if "|" not in header or header.count("|") < 1:
1184
+ return False
1185
+ return _is_markdown_table_separator(lines[index + 1])
1186
+
1187
+
1188
+ def _render_table_block(table_lines: list[str]) -> dict[str, Any] | None:
1189
+ table_text = "\n".join(line.strip() for line in table_lines if line.strip())
1190
+ if not table_text:
1191
+ return None
1192
+ return {
1193
+ "type": "box",
1194
+ "layout": "vertical",
1195
+ "contents": [
1196
+ {
1197
+ "type": "text",
1198
+ "text": "table",
1199
+ "wrap": True,
1200
+ "color": MUTED_TEXT_COLOR,
1201
+ "size": "xxs",
1202
+ "weight": "bold",
1203
+ "margin": "none",
1204
+ },
1205
+ {
1206
+ "type": "text",
1207
+ "text": table_text,
1208
+ "wrap": True,
1209
+ "color": "#333333",
1210
+ "size": "xs",
1211
+ "margin": "xs",
1212
+ },
1213
+ ],
1214
+ "backgroundColor": CODE_BLOCK_BACKGROUND,
1215
+ "paddingAll": "10px",
1216
+ "cornerRadius": "md",
1217
+ "margin": "md",
1218
+ }
1219
+
1220
+
1221
+ def _task_list_prefix(raw_list_item_text: str) -> tuple[str, str]:
1222
+ task_match = re.match(
1223
+ "^\\[(?P<mark>[ xX])\\]\\s+(?P<text>[\\s\\S]+)$", raw_list_item_text
1224
+ )
1225
+ if not task_match:
1226
+ return ("", raw_list_item_text)
1227
+ marker = "☑ " if task_match.group("mark").lower() == "x" else "☐ "
1228
+ return (marker, task_match.group("text").strip())
1229
+
1230
+
1231
+ def _render_list_item(
1232
+ bullet_text: str,
1233
+ raw_list_item_text: str,
1234
+ indent_level: int,
1235
+ *,
1236
+ ordered: bool = False,
1237
+ ) -> dict[str, Any] | None:
1238
+ task_prefix, item_text = _task_list_prefix(raw_list_item_text.strip())
1239
+ display_bullet = task_prefix or bullet_text
1240
+ if indent_level == 0:
1241
+ padding_start = "10px"
1242
+ marker = display_bullet
1243
+ elif indent_level <= 2:
1244
+ padding_start = "30px"
1245
+ marker = display_bullet if ordered or task_prefix else "◦ "
1246
+ else:
1247
+ padding_start = "50px"
1248
+ marker = display_bullet if ordered or task_prefix else "▪ "
1249
+ item_components = process_text_block_to_flex_components(item_text)
1250
+ if not item_components:
1251
+ return None
1252
+ return {
1253
+ "type": "box",
1254
+ "layout": "horizontal",
1255
+ "contents": [
1256
+ {
1257
+ "type": "text",
1258
+ "text": marker,
1259
+ "wrap": False,
1260
+ "flex": 0,
1261
+ "margin": "none",
1262
+ "gravity": "top",
1263
+ "size": "sm",
1264
+ "color": BODY_TEXT_COLOR,
1265
+ },
1266
+ {
1267
+ "type": "box",
1268
+ "layout": "vertical",
1269
+ "contents": item_components,
1270
+ "flex": 1,
1271
+ "margin": "sm",
1272
+ "spacing": "xs",
1273
+ },
1274
+ ],
1275
+ "margin": "xs",
1276
+ "paddingStart": padding_start,
1277
+ "alignItems": "flex-start",
1278
+ }
1279
+
1280
+
1281
+ def parse_text_with_links(text):
1282
+ """テキストを解析し、コードブロック、見出し、リスト、リンク、太字をFlex Messageのコンテンツに変換する。"""
1283
+ code_delimiter_pattern = "(```[\\s\\S]*?```)"
1284
+ segments = re.split(code_delimiter_pattern, text)
1285
+ contents = []
1286
+ for segment in segments:
1287
+ if segment is None or not segment.strip():
1288
+ continue
1289
+ is_code_block = re.fullmatch(code_delimiter_pattern, segment)
1290
+ if is_code_block:
1291
+ component_to_add = _render_code_block(segment)
1292
+ if component_to_add:
1293
+ contents.append(component_to_add)
1294
+ else:
1295
+ normalized_segment = "\n".join(
1296
+ _transform_outside_inline_code(
1297
+ line,
1298
+ lambda source: re.sub(
1299
+ "<br\\s*/?>", "\n", source, flags=re.IGNORECASE
1300
+ ),
1301
+ )
1302
+ for line in segment.split("\n")
1303
+ )
1304
+ lines = normalized_segment.strip().split("\n")
1305
+ normalized_lines = []
1306
+ prev_was_list = False
1307
+ for raw_line in lines:
1308
+ stripped = raw_line.strip()
1309
+ heading_match_tmp = re.match("^(#{1,6})\\s+(.*)", stripped)
1310
+ separator_line_match_tmp = re.match(
1311
+ "^(\\s*)(?:[-‐]{3,}|\\*{3,}|_{3,})\\s*$", stripped
1312
+ )
1313
+ list_match_tmp = (
1314
+ _UNORDERED_LIST_MATCH_RE.match(raw_line)
1315
+ if not separator_line_match_tmp
1316
+ else None
1317
+ )
1318
+ numbered_list_match_tmp = re.match(
1319
+ "^(\\s*)(\\d+)([.)])\\s+(.*)", raw_line
1320
+ )
1321
+ is_list_line = bool(list_match_tmp or numbered_list_match_tmp)
1322
+ should_merge_with_prev_list = (
1323
+ not is_list_line
1324
+ and prev_was_list
1325
+ and stripped
1326
+ and raw_line[:1].isspace()
1327
+ and (not heading_match_tmp)
1328
+ and (not separator_line_match_tmp)
1329
+ )
1330
+ if should_merge_with_prev_list and normalized_lines:
1331
+ continuation = raw_line.lstrip()
1332
+ prev_line = normalized_lines[-1]
1333
+ if _ends_with_markdown_hard_break(
1334
+ prev_line
1335
+ ) or _looks_like_list_item_title_line(prev_line):
1336
+ prev_line = _strip_markdown_hard_break(prev_line).rstrip()
1337
+ normalized_lines[-1] = prev_line + "\n" + continuation
1338
+ else:
1339
+ normalized_lines[-1] = prev_line.rstrip() + " " + continuation
1340
+ prev_was_list = True
1341
+ continue
1342
+ normalized_lines.append(raw_line)
1343
+ prev_was_list = is_list_line
1344
+ lines = normalized_lines
1345
+ closing_line_cache: dict[str, list[int | None]] = {}
1346
+ current_paragraph_components = []
1347
+
1348
+ def add_paragraph_if_exists(components=current_paragraph_components):
1349
+ paragraph_box = _paragraph_box(components)
1350
+ if paragraph_box:
1351
+ contents.append(paragraph_box)
1352
+ components.clear()
1353
+
1354
+ line_index = 0
1355
+ while line_index < len(lines):
1356
+ line = lines[line_index]
1357
+ line_strip = line.strip()
1358
+ if not line_strip:
1359
+ add_paragraph_if_exists()
1360
+ contents.append(_spacer_component())
1361
+ line_index += 1
1362
+ continue
1363
+ heading_match = re.match("^(#{1,6})\\s+(.*)", line_strip)
1364
+ separator_line_match = re.match(
1365
+ "^(\\s*)(?:[-‐]{3,}|\\*{3,}|_{3,})\\s*$", line_strip
1366
+ )
1367
+ list_match = (
1368
+ _UNORDERED_LIST_MATCH_RE.match(line)
1369
+ if not separator_line_match
1370
+ else None
1371
+ )
1372
+ numbered_list_match = re.match(
1373
+ "^(\\s*)(\\d+)([.)])\\s+([\\s\\S]+)", line
1374
+ )
1375
+ if _looks_like_markdown_table_start(lines, line_index):
1376
+ add_paragraph_if_exists()
1377
+ table_lines = [lines[line_index], lines[line_index + 1]]
1378
+ line_index += 2
1379
+ while line_index < len(lines):
1380
+ candidate_line = lines[line_index]
1381
+ if not candidate_line.strip() or "|" not in candidate_line:
1382
+ break
1383
+ if _TABLE_BLOCK_TERMINATOR_RE.match(candidate_line):
1384
+ break
1385
+ table_lines.append(candidate_line)
1386
+ line_index += 1
1387
+ table_component = _render_table_block(table_lines)
1388
+ if table_component:
1389
+ contents.append(table_component)
1390
+ continue
1391
+ if line_strip.startswith(">"):
1392
+ add_paragraph_if_exists()
1393
+ quote_lines: list[str] = []
1394
+ while line_index < len(lines):
1395
+ quote_line = lines[line_index].strip()
1396
+ if not quote_line.startswith(">"):
1397
+ break
1398
+ quote_lines.append(re.sub("^>\\s?", "", quote_line))
1399
+ line_index += 1
1400
+ quote_component = _render_quote_block(
1401
+ "\n".join(quote_lines).strip()
1402
+ )
1403
+ if quote_component:
1404
+ contents.append(quote_component)
1405
+ continue
1406
+ if heading_match:
1407
+ add_paragraph_if_exists()
1408
+ level = len(heading_match.group(1))
1409
+ heading_text = heading_match.group(2).strip()
1410
+ component_to_add = _render_heading(heading_text, level)
1411
+ if component_to_add:
1412
+ contents.append(component_to_add)
1413
+ line_index += 1
1414
+ continue
1415
+ elif numbered_list_match:
1416
+ add_paragraph_if_exists()
1417
+ indent_spaces = numbered_list_match.group(1)
1418
+ indent_level = len(indent_spaces)
1419
+ list_number = numbered_list_match.group(2)
1420
+ list_delimiter = numbered_list_match.group(3)
1421
+ raw_list_item_text = numbered_list_match.group(4).strip()
1422
+ component_to_add = _render_list_item(
1423
+ f"{list_number}{list_delimiter} ",
1424
+ raw_list_item_text,
1425
+ indent_level,
1426
+ ordered=True,
1427
+ )
1428
+ if component_to_add:
1429
+ contents.append(component_to_add)
1430
+ line_index += 1
1431
+ continue
1432
+ elif list_match:
1433
+ add_paragraph_if_exists()
1434
+ indent_spaces = list_match.group(1)
1435
+ indent_level = len(indent_spaces)
1436
+ raw_list_item_text = list_match.group(2).strip()
1437
+ component_to_add = _render_list_item(
1438
+ "• ", raw_list_item_text, indent_level
1439
+ )
1440
+ if component_to_add:
1441
+ contents.append(component_to_add)
1442
+ line_index += 1
1443
+ continue
1444
+ elif separator_line_match:
1445
+ add_paragraph_if_exists()
1446
+ contents.append(_render_separator())
1447
+ line_index += 1
1448
+ continue
1449
+ is_marker_line = False
1450
+ if (
1451
+ not list_match
1452
+ and (not numbered_list_match)
1453
+ and (not heading_match)
1454
+ and line_strip
1455
+ ):
1456
+ if is_char_emoji(line_strip[0]):
1457
+ is_marker_line = True
1458
+ if is_marker_line:
1459
+ add_paragraph_if_exists()
1460
+ marker_multiline_block = _collect_multiline_inline_block(
1461
+ lines, line_index, closing_line_cache
1462
+ )
1463
+ if marker_multiline_block:
1464
+ block_text, next_line_index = marker_multiline_block
1465
+ marker_line_components = process_text_block_to_flex_components(
1466
+ block_text
1467
+ )
1468
+ line_index = next_line_index
1469
+ else:
1470
+ marker_line_components = process_line_to_flex_components(
1471
+ line_strip
1472
+ )
1473
+ line_index += 1
1474
+ if marker_line_components:
1475
+ component_to_add = {
1476
+ "type": "box",
1477
+ "layout": "vertical",
1478
+ "contents": marker_line_components,
1479
+ "margin": "md",
1480
+ "spacing": "xs",
1481
+ }
1482
+ contents.append(component_to_add)
1483
+ continue
1484
+ else:
1485
+ space_indent_match = re.match("^(\\s+)(.+)", line)
1486
+ if (
1487
+ space_indent_match
1488
+ and (not list_match)
1489
+ and (not numbered_list_match)
1490
+ ):
1491
+ add_paragraph_if_exists()
1492
+ indent_spaces = space_indent_match.group(1)
1493
+ indent_level = len(indent_spaces)
1494
+ indented_text = space_indent_match.group(2).strip()
1495
+ if indent_level <= 2:
1496
+ padding_start = "20px"
1497
+ elif indent_level <= 4:
1498
+ padding_start = "40px"
1499
+ else:
1500
+ padding_start = "60px"
1501
+ if indented_text:
1502
+ indented_flex_components_list = (
1503
+ process_line_to_flex_components(indented_text)
1504
+ )
1505
+ if indented_flex_components_list:
1506
+ component_to_add = {
1507
+ "type": "box",
1508
+ "layout": "vertical",
1509
+ "contents": indented_flex_components_list,
1510
+ "margin": "xs",
1511
+ "paddingStart": padding_start,
1512
+ }
1513
+ contents.append(component_to_add)
1514
+ line_index += 1
1515
+ continue
1516
+ multiline_inline_block = _collect_multiline_inline_block(
1517
+ lines, line_index, closing_line_cache
1518
+ )
1519
+ if multiline_inline_block:
1520
+ block_text, next_line_index = multiline_inline_block
1521
+ line_flex_components_list = (
1522
+ process_text_block_to_flex_components(block_text)
1523
+ )
1524
+ if line_flex_components_list:
1525
+ current_paragraph_components.extend(
1526
+ comp for comp in line_flex_components_list if comp
1527
+ )
1528
+ line_index = next_line_index
1529
+ continue
1530
+ line_flex_components_list = process_line_to_flex_components(
1531
+ line_strip
1532
+ )
1533
+ if line_flex_components_list:
1534
+ current_paragraph_components.extend(
1535
+ comp for comp in line_flex_components_list if comp
1536
+ )
1537
+ line_index += 1
1538
+ add_paragraph_if_exists()
1539
+ if not contents and text.strip():
1540
+ contents.extend(process_line_to_flex_components(text.strip()))
1541
+ return contents
1542
+
1543
+
1544
+ def _is_separator_text_component(component: dict) -> bool:
1545
+ """Flexのテキストコンポーネントが区切り線かどうかを判定する。"""
1546
+ if not isinstance(component, dict):
1547
+ return False
1548
+ if component.get("type") != "text":
1549
+ return False
1550
+ text_value = component.get("text", "")
1551
+ if not isinstance(text_value, str):
1552
+ return False
1553
+ return re.fullmatch("\\s*[-‐─]{3,}\\s*", text_value) is not None
1554
+
1555
+
1556
+ def build_flex_contents(text_message: str) -> list[dict]:
1557
+ """Render Markdown as Flex body components, without sending or logging it."""
1558
+ if not isinstance(text_message, str):
1559
+ raise TypeError("text_message must be a string")
1560
+ contents = parse_text_with_links(text_message)
1561
+ while contents and _is_separator_text_component(contents[0]):
1562
+ contents.pop(0)
1563
+ while contents and _is_separator_text_component(contents[-1]):
1564
+ contents.pop()
1565
+ return contents