md2lineflex 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 parkwestlabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
18
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
19
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
20
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
21
+ OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: md2lineflex
3
+ Version: 0.1.0
4
+ Summary: Markdown to LINE FlexMessage Converter
5
+ Keywords: markdown,LINE,flexmessage
6
+ Author: parkwestlabs
7
+ Author-email: parkwestlabs <281970052+parkwestlabs@users.noreply.github.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Dist: line-bot-sdk>=3.25.0
11
+ Requires-Dist: mdformat>=1.0.0
12
+ Requires-Dist: mdformat-gfm>=1.0.0
13
+ Requires-Dist: mistletoe>=1.6.0
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+
17
+ # md2lineflex - Markdown to LINE FlexMessage Converter
18
+
19
+ [![PyPI version](https://badge.fury.io/py/md2lineflex.svg)](https://badge.fury.io/py/md2lineflex)
20
+ [![Python Versions](https://img.shields.io/pypi/pyversions/md2lineflex.svg)](https://pypi.org/project/md2lineflex/)
21
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
22
+
23
+ A lightweight Python library that converts **Markdown text into LINE Bot Flex Messages** effortlessly.
24
+
25
+ Designed especially for LLM / Chatbot developers who want to render Markdown outputs (from OpenAI, Claude, etc.) as beautiful, structured LINE Flex Messages instead of plain text.
26
+
27
+ ---
28
+
29
+ ## Features
30
+
31
+ - 🚀 **Zero-Config Conversion:** Simply pass a Markdown string and get a LINE Messaging API compatible `FlexMessage` object or `dict`.
32
+ - 🎨 **Rich Component Support:** Supports Headings, Paragraphs, Lists, Code Blocks, Quotes, Links, Images, and Tables.
33
+ - 🔗 **Flexible Link Handling:** Render links as embedded URLs, inline text, or interactive LINE URI Action Buttons.
34
+ - 🛡️ **Type Safe:** Fully typed with `py.typed` marker (PEP 561) for modern IDE auto-completion.
35
+
36
+ ---
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ # Using uv (Recommended)
42
+ uv add md2lineflex
43
+
44
+ # Using pip
45
+ pip install md2lineflex
46
+ ```
47
+
48
+ Prerequisite: Python `>= 3.10` and `line-bot-sdk >= 3.0.0`
49
+
50
+ ---
51
+
52
+ ## Quickstart
53
+
54
+ ````python
55
+ from linebot.v3.messaging import PushMessageRequest
56
+ from md2lineflex import to_flex
57
+
58
+ md_text = """
59
+ ## Hello LINE Bot!
60
+
61
+ This is **bold text** generated from Markdown.
62
+
63
+ - Feature 1: Automatic Flex Box layout
64
+ - Feature 2: Supports [Links](https://example.com)
65
+
66
+ ```python
67
+ print("Hello World")
68
+ ```
69
+ """
70
+
71
+ # Convert Markdown to FlexMessage object
72
+ flex_message = to_flex(md_text, link_mode="button")
73
+
74
+ # Send via LINE Messaging API SDK
75
+ request = PushMessageRequest(to="USER_ID", messages=[flex_message])
76
+ messaging_api.push_message(request)
77
+ ````
78
+
79
+ ---
80
+
81
+ ## Options
82
+
83
+ ### Link Modes (`link_mode`)
84
+
85
+ You can control how Markdown links `[Title](https://...)` are rendered in the Flex Message:
86
+
87
+ | Mode | Description |
88
+ | :--- | :--- |
89
+ | `button` **(Default)** | Appends interactive URI action buttons below the text block. |
90
+ | `action` | Renders links in text format: `Title (https://...)`. |
91
+ | `url_text` | Renders links in text format: `Title [https://...]`. |
92
+
93
+ ```python
94
+ flex_msg = to_flex(md_text, link_mode="button")
95
+ ```
96
+
97
+ ---
98
+
99
+ ## Development & Contributing
100
+
101
+ See [DEVELOPMENT.md](DEVELOPMENT.md) for local setup, running tests, and contribution guidelines.
102
+
103
+ ---
104
+
105
+ ## License
106
+
107
+ This project is licensed under the MIT License.
@@ -0,0 +1,91 @@
1
+ # md2lineflex - Markdown to LINE FlexMessage Converter
2
+
3
+ [![PyPI version](https://badge.fury.io/py/md2lineflex.svg)](https://badge.fury.io/py/md2lineflex)
4
+ [![Python Versions](https://img.shields.io/pypi/pyversions/md2lineflex.svg)](https://pypi.org/project/md2lineflex/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ A lightweight Python library that converts **Markdown text into LINE Bot Flex Messages** effortlessly.
8
+
9
+ Designed especially for LLM / Chatbot developers who want to render Markdown outputs (from OpenAI, Claude, etc.) as beautiful, structured LINE Flex Messages instead of plain text.
10
+
11
+ ---
12
+
13
+ ## Features
14
+
15
+ - 🚀 **Zero-Config Conversion:** Simply pass a Markdown string and get a LINE Messaging API compatible `FlexMessage` object or `dict`.
16
+ - 🎨 **Rich Component Support:** Supports Headings, Paragraphs, Lists, Code Blocks, Quotes, Links, Images, and Tables.
17
+ - 🔗 **Flexible Link Handling:** Render links as embedded URLs, inline text, or interactive LINE URI Action Buttons.
18
+ - 🛡️ **Type Safe:** Fully typed with `py.typed` marker (PEP 561) for modern IDE auto-completion.
19
+
20
+ ---
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ # Using uv (Recommended)
26
+ uv add md2lineflex
27
+
28
+ # Using pip
29
+ pip install md2lineflex
30
+ ```
31
+
32
+ Prerequisite: Python `>= 3.10` and `line-bot-sdk >= 3.0.0`
33
+
34
+ ---
35
+
36
+ ## Quickstart
37
+
38
+ ````python
39
+ from linebot.v3.messaging import PushMessageRequest
40
+ from md2lineflex import to_flex
41
+
42
+ md_text = """
43
+ ## Hello LINE Bot!
44
+
45
+ This is **bold text** generated from Markdown.
46
+
47
+ - Feature 1: Automatic Flex Box layout
48
+ - Feature 2: Supports [Links](https://example.com)
49
+
50
+ ```python
51
+ print("Hello World")
52
+ ```
53
+ """
54
+
55
+ # Convert Markdown to FlexMessage object
56
+ flex_message = to_flex(md_text, link_mode="button")
57
+
58
+ # Send via LINE Messaging API SDK
59
+ request = PushMessageRequest(to="USER_ID", messages=[flex_message])
60
+ messaging_api.push_message(request)
61
+ ````
62
+
63
+ ---
64
+
65
+ ## Options
66
+
67
+ ### Link Modes (`link_mode`)
68
+
69
+ You can control how Markdown links `[Title](https://...)` are rendered in the Flex Message:
70
+
71
+ | Mode | Description |
72
+ | :--- | :--- |
73
+ | `button` **(Default)** | Appends interactive URI action buttons below the text block. |
74
+ | `action` | Renders links in text format: `Title (https://...)`. |
75
+ | `url_text` | Renders links in text format: `Title [https://...]`. |
76
+
77
+ ```python
78
+ flex_msg = to_flex(md_text, link_mode="button")
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Development & Contributing
84
+
85
+ See [DEVELOPMENT.md](DEVELOPMENT.md) for local setup, running tests, and contribution guidelines.
86
+
87
+ ---
88
+
89
+ ## License
90
+
91
+ This project is licensed under the MIT License.
@@ -0,0 +1,73 @@
1
+ [project]
2
+ name = "md2lineflex"
3
+ version = "0.1.0"
4
+ description = "Markdown to LINE FlexMessage Converter"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ keywords = [
9
+ "markdown",
10
+ "LINE",
11
+ "flexmessage",
12
+ ]
13
+ requires-python = ">=3.10"
14
+ dependencies = [
15
+ "line-bot-sdk>=3.25.0",
16
+ "mdformat>=1.0.0",
17
+ "mdformat-gfm>=1.0.0",
18
+ "mistletoe>=1.6.0",
19
+ ]
20
+
21
+ [[project.authors]]
22
+ name = "parkwestlabs"
23
+ email = "281970052+parkwestlabs@users.noreply.github.com"
24
+
25
+ [build-system]
26
+ requires = ["uv_build>=0.12.5,<0.13.0"]
27
+ build-backend = "uv_build"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest>=9.1.1",
32
+ "pytest-cov>=7.1.0",
33
+ "ruff>=0.16.4",
34
+ "syrupy>=5.5.3",
35
+ ]
36
+
37
+ [tool.ruff.lint]
38
+ select = ["ALL"]
39
+ ignore = [
40
+ "D",
41
+ "TC",
42
+ "CPY",
43
+ "COM812",
44
+ "G004",
45
+ "INP001",
46
+ ]
47
+ allowed-confusables = [
48
+ "(",
49
+ ")",
50
+ ":",
51
+ "?",
52
+ "!",
53
+ "&",
54
+ "+",
55
+ ]
56
+
57
+ [tool.ruff.lint.per-file-ignores]
58
+ "tests/**/*" = [
59
+ "S101",
60
+ "ANN201",
61
+ "PLR2004",
62
+ "ERA001",
63
+ ]
64
+
65
+ [tool.pytest.ini_options]
66
+ testpaths = ["tests"]
67
+ addopts = "--cov=src --cov-branch --cov-report=term-missing --cov-report=html"
68
+
69
+ [tool.coverage.report]
70
+ fail_under = 90
71
+
72
+ [tool.coverage.run]
73
+ omit = ["*/__init__.py"]
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "md2lineflex"
3
+ version = "0.1.0"
4
+ description = "Markdown to LINE FlexMessage Converter"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ keywords = ["markdown", "LINE", "flexmessage"]
9
+ authors = [
10
+ { name = "parkwestlabs", email = "281970052+parkwestlabs@users.noreply.github.com" }
11
+ ]
12
+ requires-python = ">=3.10"
13
+ dependencies = [
14
+ "line-bot-sdk>=3.25.0",
15
+ "mdformat>=1.0.0",
16
+ "mdformat-gfm>=1.0.0",
17
+ "mistletoe>=1.6.0",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.12.5,<0.13.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "pytest>=9.1.1",
27
+ "pytest-cov>=7.1.0",
28
+ "ruff>=0.16.4",
29
+ "syrupy>=5.5.3",
30
+ ]
31
+
32
+ [tool.ruff.lint]
33
+ select = ["ALL"]
34
+ ignore = [
35
+ "D",
36
+ "TC",
37
+ "CPY",
38
+ "COM812",
39
+ "G004", # Logging statement uses f-string
40
+ "INP001", # an implicit namespace package
41
+ ]
42
+ # RUF001, RUF002, RUF003 のすべてで「?」を無視する
43
+ allowed-confusables = ["(", ")", ":", "?", "!", "&", "+"]
44
+
45
+ [tool.ruff.lint.per-file-ignores]
46
+ "tests/**/*" = [
47
+ "S101", # Use of assert
48
+ "ANN201", # Missing return type annotation for public function
49
+ "PLR2004", # Magic value used in comparison
50
+ "ERA001", # Found commented-out code
51
+ ]
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
55
+ addopts = "--cov=src --cov-branch --cov-report=term-missing --cov-report=html"
56
+
57
+ [tool.coverage.report]
58
+ fail_under = 90
59
+
60
+ [tool.coverage.run]
61
+ omit = [
62
+ "*/__init__.py",
63
+ ]
@@ -0,0 +1,11 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from md2lineflex.md_to_flex import to_flex
4
+
5
+ try:
6
+ __version__ = version("md2lineflex")
7
+ except PackageNotFoundError:
8
+ # 開発中(未インストール状態)のフォールバック
9
+ __version__ = "0.0.0"
10
+
11
+ __all__ = ["__version__", "to_flex"]
@@ -0,0 +1,214 @@
1
+ from linebot.v3.messaging import (
2
+ FlexBox,
3
+ FlexButton,
4
+ FlexComponent,
5
+ FlexImage,
6
+ FlexSeparator,
7
+ FlexSpan,
8
+ FlexText,
9
+ URIAction,
10
+ )
11
+
12
+ from md2lineflex.theme import EMPTY_CHAR, Color, Margin, Size
13
+
14
+ # 共通フォールバックオブジェクト
15
+ EMPTY_SPAN = FlexSpan(text=EMPTY_CHAR)
16
+ # contents に渡す用のリスト(読み取り専用想定)
17
+ EMPTY_SPANS = [EMPTY_SPAN]
18
+
19
+
20
+ def heading_component(text: str, level: int) -> FlexText:
21
+ # text が空文字や None だった場合に半角スペース1文字にフォールバック
22
+ safe_text = text if text and text.strip() else EMPTY_CHAR
23
+
24
+ size_map = {1: Size.HEADING_H1, 2: Size.HEADING_H2}
25
+ size = size_map.get(level, Size.BODY)
26
+
27
+ return FlexText(
28
+ text=safe_text,
29
+ weight="bold",
30
+ size=size,
31
+ color=Color.HEADING,
32
+ wrap=True,
33
+ margin=Margin.HEADING,
34
+ ) # pyright: ignore[reportCallIssue]
35
+
36
+
37
+ def paragraph_component(spans: list[FlexSpan]) -> FlexText:
38
+ return FlexText(
39
+ size=Size.BODY,
40
+ wrap=True,
41
+ margin=Margin.PARAGRAPH,
42
+ color=Color.BODY,
43
+ contents=spans or EMPTY_SPANS,
44
+ ) # pyright: ignore[reportCallIssue]
45
+
46
+
47
+ def code_block_component(raw_code: str) -> FlexBox:
48
+ return FlexBox(
49
+ layout="vertical",
50
+ backgroundColor=Color.BG_CODE_BLOCK,
51
+ cornerRadius="sm",
52
+ paddingAll="md",
53
+ margin=Margin.BLOCK,
54
+ contents=[
55
+ FlexText(
56
+ text=raw_code or EMPTY_CHAR,
57
+ size=Size.CODE,
58
+ color=Color.CODE_BLOCK_TEXT, # abb2bf
59
+ wrap=True,
60
+ ) # pyright: ignore[reportCallIssue]
61
+ ],
62
+ )
63
+
64
+
65
+ def list_item_component(
66
+ prefix: str,
67
+ spans: list[FlexSpan],
68
+ extras: list[FlexButton] | None = None,
69
+ ) -> FlexBox:
70
+ # 行頭記号用テキスト
71
+ prefix_component = FlexText(
72
+ text=prefix or EMPTY_CHAR,
73
+ size=Size.BODY,
74
+ color=Color.MUTED,
75
+ flex=0, # 幅固定
76
+ ) # pyright: ignore[reportCallIssue]
77
+
78
+ # 本文用テキスト (contents に spans を配置)
79
+ text_component = FlexText(
80
+ text=EMPTY_CHAR, # デフォルト
81
+ size=Size.BODY,
82
+ color=Color.BODY,
83
+ wrap=True,
84
+ flex=1, # 残りの幅を占有
85
+ contents=spans or EMPTY_SPANS,
86
+ ) # pyright: ignore[reportCallIssue]
87
+
88
+ row_box = FlexBox(
89
+ layout="baseline",
90
+ spacing="sm",
91
+ contents=[prefix_component, text_component],
92
+ ) # pyright: ignore[reportCallIssue]
93
+
94
+ item_contents: list[FlexComponent] = [row_box]
95
+
96
+ # リンクボタン等の追加要素(extras)があれば下部に追加
97
+ if extras:
98
+ item_contents.extend(extras)
99
+
100
+ return FlexBox(
101
+ layout="vertical",
102
+ spacing="xs",
103
+ margin=Margin.LIST_ITEM,
104
+ contents=item_contents,
105
+ ) # pyright: ignore[reportCallIssue]
106
+
107
+
108
+ def quote_box_component(contents: list[FlexComponent]) -> FlexBox:
109
+ # 引用テキストにスタイル (斜体・グレー色) を適用
110
+ for item in contents:
111
+ if isinstance(item, FlexText):
112
+ item.style = "italic"
113
+ item.color = Color.QUOTE
114
+
115
+ # contents が万が一空の場合は空のテキストコンポーネントを補填 (None 回避)
116
+ if not contents:
117
+ empty_text = FlexText(
118
+ size=Size.BODY,
119
+ style="italic",
120
+ color=Color.QUOTE,
121
+ wrap=True,
122
+ contents=EMPTY_SPANS,
123
+ ) # pyright: ignore[reportCallIssue]
124
+ contents = [empty_text]
125
+
126
+ # 引用デザインを適用した FlexBox
127
+ return FlexBox(
128
+ type="box",
129
+ layout="vertical",
130
+ spacing="xs",
131
+ backgroundColor=Color.BG_QUOTE,
132
+ borderColor=Color.BORDER,
133
+ borderWidth="semi-bold",
134
+ paddingAll="md",
135
+ margin=Margin.BLOCK,
136
+ contents=contents, # 絶対に None にならない list
137
+ ) # pyright: ignore[reportCallIssue]
138
+
139
+
140
+ def link_button_component(link_text: str, target_url: str) -> FlexButton:
141
+ return FlexButton(
142
+ style="secondary",
143
+ height="sm",
144
+ margin="sm",
145
+ action=URIAction(
146
+ type="uri",
147
+ label=f"開く: {link_text[:12]}",
148
+ uri=target_url,
149
+ ), # pyright: ignore[reportCallIssue]
150
+ ) # pyright: ignore[reportCallIssue]
151
+
152
+
153
+ def image_component(url: str) -> FlexImage:
154
+ return FlexImage(
155
+ url=url,
156
+ size="full",
157
+ aspectMode="fit",
158
+ margin=Margin.BLOCK,
159
+ ) # pyright: ignore[reportCallIssue]
160
+
161
+
162
+ def separator_component() -> FlexSeparator:
163
+ return FlexSeparator(margin=Margin.HEADING, color=Color.SEPARATOR)
164
+
165
+
166
+ def table_cell_component(
167
+ spans: list[FlexSpan], flex: int = 1, *, is_header: bool = False
168
+ ) -> FlexBox:
169
+ """表の 1 セルを生成 (ヘッダーの場合は太字・背景色を設定)"""
170
+ # ヘッダーの場合は文字を太字に補正(個別指定がなければ)
171
+ if is_header:
172
+ for span in spans:
173
+ if not span.weight:
174
+ span.weight = "bold"
175
+
176
+ text_comp = FlexText(
177
+ type="text",
178
+ contents=spans or EMPTY_SPANS,
179
+ size=Size.TABLE_CELL,
180
+ color=Color.HEADING if is_header else Color.BODY,
181
+ wrap=True,
182
+ flex=flex,
183
+ ) # pyright: ignore[reportCallIssue]
184
+
185
+ return FlexBox(
186
+ layout="vertical",
187
+ contents=[text_comp],
188
+ flex=flex,
189
+ paddingAll="xs",
190
+ backgroundColor=Color.BG_TABLE_HEADER if is_header else Color.BG_TABLE_BODY,
191
+ ) # pyright: ignore[reportCallIssue]
192
+
193
+
194
+ def table_row_component(cells: list[FlexBox], *, is_header: bool = False) -> FlexBox:
195
+ """表の 1 行を横並び (horizontal) で生成"""
196
+ return FlexBox(
197
+ layout="horizontal",
198
+ contents=cells,
199
+ spacing="xs",
200
+ borderWidth="1px" if is_header else "none",
201
+ borderColor=Color.BORDER,
202
+ ) # pyright: ignore[reportCallIssue]
203
+
204
+
205
+ def table_component(rows: list[FlexBox]) -> FlexBox:
206
+ """表全体のコンテナを生成"""
207
+ return FlexBox(
208
+ layout="vertical",
209
+ contents=rows,
210
+ spacing="2px", # 行間の微小スキマ(区切り線っぽく見せる効果)
211
+ margin=Margin.BLOCK,
212
+ backgroundColor=Color.BG_TABLE_BORDER, # 背景色をグレーにして枠線効果を出す
213
+ paddingAll="1px",
214
+ ) # pyright: ignore[reportCallIssue]
@@ -0,0 +1,341 @@
1
+ import json
2
+ from typing import Literal
3
+
4
+ from linebot.v3.messaging import (
5
+ FlexBox,
6
+ FlexBubble,
7
+ FlexButton,
8
+ FlexComponent,
9
+ FlexSpan,
10
+ FlexText,
11
+ )
12
+ from mistletoe.base_renderer import BaseRenderer
13
+ from mistletoe.block_token import (
14
+ BlockCode,
15
+ CodeFence,
16
+ Document,
17
+ Heading,
18
+ List,
19
+ ListItem,
20
+ Paragraph,
21
+ Quote,
22
+ Table,
23
+ TableCell,
24
+ TableRow,
25
+ ThematicBreak,
26
+ )
27
+ from mistletoe.span_token import (
28
+ Emphasis,
29
+ HtmlSpan,
30
+ Image,
31
+ InlineCode,
32
+ LineBreak,
33
+ Link,
34
+ RawText,
35
+ Strikethrough,
36
+ Strong,
37
+ )
38
+ from mistletoe.token import Token
39
+
40
+ from md2lineflex.flex_components import (
41
+ code_block_component,
42
+ heading_component,
43
+ image_component,
44
+ link_button_component,
45
+ list_item_component,
46
+ paragraph_component,
47
+ quote_box_component,
48
+ separator_component,
49
+ table_cell_component,
50
+ table_component,
51
+ table_row_component,
52
+ )
53
+ from md2lineflex.str_utils import to_json_utf8
54
+
55
+ LinkMode = Literal["url_text", "action", "button"]
56
+
57
+ # content 属性を持つ末端トークンの型定義 (型エイリアス)
58
+ LeafToken = RawText | LineBreak | HtmlSpan
59
+
60
+ # LINE Flex Message の構造
61
+ # - ブロック要素: FlexBox や FlexBubble(レイアウトの入れ物)
62
+ # - インライン要素: FlexText の中に入れる FlexSpan(文字装飾)
63
+
64
+ # mistletoe → LINE Flex Message
65
+ # Paragraph, Heading, List 等:
66
+ # render_* で受けて FlexComponent を組み立てる
67
+ # Strong, Emphasis, Link 等:
68
+ # _parse_span_tokens と _convert_child_to_span で一括して FlexSpan 群に変換する
69
+
70
+
71
+ class FlexMessageRenderer(BaseRenderer):
72
+ """Markdown AST を LINE Flex Message (Bubble) 用の JSON 文字列に変換するRenderer"""
73
+
74
+ def __init__(self, link_mode: LinkMode = "url_text") -> None:
75
+ super().__init__()
76
+ self.link_mode: LinkMode = link_mode
77
+
78
+ # --- インライン要素 (spans & extra components) の抽出 ---
79
+
80
+ def _parse_span_tokens(
81
+ self, token: Token
82
+ ) -> tuple[list[FlexSpan], list[FlexButton]]:
83
+ """子要素から Flex Text 用の spans と、追加のコンポーネント (ボタン等) を抽出"""
84
+ spans: list[FlexSpan] = []
85
+ extras: list[FlexButton] = []
86
+
87
+ for child in token.children or []:
88
+ span = self._convert_child_to_span(child, self.link_mode)
89
+ if span:
90
+ spans.append(span)
91
+
92
+ # リンクボタン (extra) の抽出 (Link かつ button モード時のみ)
93
+ if isinstance(child, Link) and self.link_mode == "button":
94
+ link_text = self._get_plain_text(child)
95
+ link_button = link_button_component(link_text, child.target)
96
+ extras.append(link_button)
97
+
98
+ # Paragraph やその他の容器トークンの場合は再帰的に子要素を解析
99
+ elif child.children:
100
+ sub_spans, sub_extras = self._parse_span_tokens(child)
101
+ spans.extend(sub_spans)
102
+ extras.extend(sub_extras)
103
+
104
+ # その他の未対応インライン要素(安全策)
105
+ else:
106
+ fallback_text = self._get_plain_text(child)
107
+ if fallback_text:
108
+ spans.append(FlexSpan(text=fallback_text))
109
+
110
+ return spans, extras
111
+
112
+ def _convert_child_to_span( # noqa: PLR0911
113
+ self, child: Token, link_mode: LinkMode
114
+ ) -> FlexSpan | None:
115
+ text = self._get_plain_text(child)
116
+
117
+ match child:
118
+ case RawText():
119
+ # 空文字の場合は半角スペースにして LINE 仕様違反を防ぐ
120
+ return FlexSpan(text=child.content or " ")
121
+ case LineBreak():
122
+ return FlexSpan(text="\n")
123
+ case Strong(): # 太字
124
+ return FlexSpan(text=text, weight="bold")
125
+ case Emphasis(): # 斜体
126
+ return FlexSpan(text=text, style="italic", color="#555555")
127
+ case Strikethrough():
128
+ return FlexSpan(text=text, decoration="line-through")
129
+ case InlineCode(): # 行内コード
130
+ return FlexSpan(text=f" {text} ", color="#0052cc", weight="bold")
131
+ case Link(): # リンク ([text](url))
132
+ if link_mode == "action":
133
+ return FlexSpan(text=f"{text} ({child.target})", color="#06c755", weight="bold") # fmt: skip # noqa: E501
134
+ if link_mode == "button":
135
+ return FlexSpan(text=text, weight="bold", color="#06c755")
136
+ return FlexSpan(text=f"{text} [{child.target}]")
137
+ case _:
138
+ return None
139
+
140
+ def _get_plain_text(self, token: Token) -> str:
141
+ """純粋な文字列だけを取り出すヘルパー"""
142
+
143
+ # 1. content 属性を持つ末端ノードなら文字列を返す
144
+ if isinstance(token, LeafToken):
145
+ return token.content
146
+
147
+ # 2. children を持つ容器ノードなら子要素をたどる (再帰処理)
148
+ if token.children is not None:
149
+ return "".join(self._get_plain_text(child) for child in token.children)
150
+
151
+ # 3. Image などの特殊要素、又は children が None の場合はテキストなし (空文字)
152
+ return ""
153
+
154
+ # -------------------------------------------------------------------------
155
+ # Helper Methods (共通処理)
156
+ # -------------------------------------------------------------------------
157
+ def _render_children(self, token: Token) -> list[FlexComponent]:
158
+ """
159
+ 子トークン群をレンダリングし、FlexComponent オブジェクトのリストとして集約する
160
+ """
161
+ contents: list[FlexComponent] = []
162
+
163
+ for child in token.children or []:
164
+ rendered_str = self.render(child)
165
+ if not rendered_str or not rendered_str.strip():
166
+ continue
167
+
168
+ parsed = json.loads(rendered_str.strip())
169
+
170
+ # 単体要素をリストに統一してイテレート処理を共通化
171
+ items = parsed if isinstance(parsed, list) else [parsed]
172
+
173
+ contents.extend([FlexComponent.from_dict(item) for item in items])
174
+
175
+ return contents
176
+
177
+ # -------------------------------------------------------------------------
178
+ # Block Renderers (ブロック要素のレンダリング)
179
+ # -------------------------------------------------------------------------
180
+ def render_document(self, token: Document) -> str:
181
+ contents = self._render_children(token)
182
+
183
+ # ドキュメントが空の場合の最小表示保証
184
+ if not contents:
185
+ # 空の Markdown の場合は安全なプレースホルダーテキストを設置
186
+ contents = [heading_component(" ", 3)]
187
+
188
+ # FlexBox と FlexBubble のインスタンスを from_dict で生成する
189
+ body_box = FlexBox(
190
+ layout="vertical",
191
+ spacing="md",
192
+ contents=contents,
193
+ ) # pyright: ignore[reportCallIssue]
194
+
195
+ bubble = FlexBubble(body=body_box)
196
+
197
+ return to_json_utf8(bubble.to_dict())
198
+
199
+ def render_heading(self, token: Heading) -> str:
200
+ text = self._get_plain_text(token)
201
+ heading = heading_component(text, token.level)
202
+ return to_json_utf8(heading.to_dict())
203
+
204
+ def render_paragraph(self, token: Paragraph) -> str:
205
+ children = list(token.children or [])
206
+
207
+ # 段落内に画像単体のみが含まれる場合の判定
208
+ if len(children) == 1 and isinstance(children[0], Image):
209
+ img_token = children[0]
210
+ image = image_component(img_token.src)
211
+ return to_json_utf8(image.to_dict())
212
+
213
+ spans, extras = self._parse_span_tokens(token)
214
+
215
+ # 段落テキスト(FlexText オブジェクト)を作成
216
+ para = paragraph_component(spans)
217
+
218
+ # 段落本体と追加のリンクボタン群をまとめる(すべて FlexComponent)
219
+ components: list[FlexComponent] = [para, *extras]
220
+
221
+ # リストを JSON 化して返却
222
+ return to_json_utf8([c.to_dict() for c in components])
223
+
224
+ def render_list(self, token: List) -> str:
225
+ """リスト全体をレンダリング"""
226
+ list_boxes: list[FlexComponent] = []
227
+
228
+ # 連番管理用の状態フラグとカウンター
229
+ is_ordered: bool | None = None
230
+ current_num = 0
231
+
232
+ # 最初の ListItem の leader (例: "1.", "-", "*") から番号付きリストか判定
233
+ for child in token.children or []:
234
+ if not isinstance(child, ListItem):
235
+ continue
236
+
237
+ if is_ordered is None:
238
+ leader = child.leader
239
+ clean_leader = leader.strip().rstrip(".")
240
+
241
+ # プレフィックスの先頭が数字(例: "1")なら番号付きリストと判定
242
+ is_ordered = clean_leader.isdigit()
243
+ if is_ordered:
244
+ current_num = int(clean_leader)
245
+
246
+ # プレフィックスの生成
247
+ if is_ordered:
248
+ prefix = f"{current_num}."
249
+ current_num += 1 # 2, 3... と自動インクリメント
250
+ else:
251
+ prefix = "•"
252
+
253
+ # 子要素 (インライン装飾) の解析
254
+ item_json = self.render_list_item(child, prefix)
255
+ item_dict = json.loads(item_json)
256
+ list_boxes.append(FlexComponent.from_dict(item_dict))
257
+
258
+ list_container = FlexBox(
259
+ layout="vertical",
260
+ spacing="xs",
261
+ margin="md",
262
+ contents=list_boxes,
263
+ ) # pyright: ignore[reportCallIssue]
264
+ return to_json_utf8(list_container.to_dict())
265
+
266
+ def render_list_item(self, token: ListItem, prefix: str = "•") -> str:
267
+ """リスト項目 1 つを JSON 文字列としてレンダリング"""
268
+ spans: list[FlexSpan] = []
269
+ extras: list[FlexButton] = []
270
+
271
+ # ListItem の子要素(Paragraph や RawText 等)から Span と Extras を抽出
272
+ for child in token.children or []:
273
+ child_spans, child_extras = self._parse_span_tokens(child)
274
+ spans.extend(child_spans)
275
+ extras.extend(child_extras)
276
+
277
+ list_item = list_item_component(prefix, spans, extras)
278
+
279
+ return to_json_utf8(list_item.to_dict())
280
+
281
+ def render_block_code(self, token: CodeFence | BlockCode) -> str:
282
+ """CodeFence と BlockCode の両方がここにルーティングされる"""
283
+ raw_code = self._get_plain_text(token).strip()
284
+ code_block = code_block_component(raw_code)
285
+ return to_json_utf8(code_block.to_dict())
286
+
287
+ def render_quote(self, token: Quote) -> str:
288
+ """引用 (>) ブロックの描画"""
289
+ contents = self._render_children(token)
290
+
291
+ # 引用ブロック内の全テキスト要素の上マージンを無効化して余白重複を防ぐ
292
+ for item in contents:
293
+ if isinstance(item, FlexText):
294
+ item.margin = "none"
295
+
296
+ quote_box = quote_box_component(contents)
297
+ return to_json_utf8(quote_box.to_dict())
298
+
299
+ def render_thematic_break(self, token: ThematicBreak) -> str: # noqa: ARG002
300
+ """水平線 (---)"""
301
+ separator = separator_component()
302
+ return to_json_utf8(separator.to_dict())
303
+
304
+ def render_table(self, token: Table) -> str:
305
+ """Markdown Table を FlexBox のグリッドレイアウトとして描画"""
306
+ row_boxes: list[FlexBox] = []
307
+
308
+ # ヘッダー行 (token.header) の処理
309
+ if token.header is not None:
310
+ header_box = self._render_table_row(token.header, is_header=True)
311
+ if header_box:
312
+ row_boxes.append(header_box)
313
+
314
+ # 本文データ行 (token.children) の処理
315
+ for child in token.children or []:
316
+ # Table の children には必ず TableRow が入っているはず
317
+ if not isinstance(child, TableRow):
318
+ continue
319
+
320
+ row_box = self._render_table_row(child, is_header=False)
321
+ if row_box:
322
+ row_boxes.append(row_box)
323
+
324
+ # 表全体 Component の作成
325
+ table_container = table_component(row_boxes)
326
+ return to_json_utf8(table_container.to_dict())
327
+
328
+ def _render_table_row(self, row: TableRow, *, is_header: bool) -> FlexBox | None:
329
+ cell_boxes: list[FlexBox] = []
330
+
331
+ for cell in row.children or []:
332
+ if isinstance(cell, TableCell):
333
+ spans, _ = self._parse_span_tokens(cell)
334
+ if not spans:
335
+ spans = [FlexSpan(text=" ")] # 空セル対策
336
+ cell_boxes.append(table_cell_component(spans, is_header=is_header))
337
+
338
+ if not cell_boxes:
339
+ return None
340
+
341
+ return table_row_component(cell_boxes, is_header=is_header)
@@ -0,0 +1,14 @@
1
+ import re
2
+
3
+ import mdformat
4
+
5
+
6
+ def clean_llm_output(text: str) -> str:
7
+ # 1. 太字のアスタリスクを除去
8
+ no_bold = re.sub(r"\*\*(.*?)\*\*", r"\1", text)
9
+
10
+ # 2. リストやテーブルを含めて綺麗なMarkdownに整形
11
+ formatted_text = mdformat.text(no_bold, extensions=["gfm"])
12
+
13
+ # 3. チャットUI用に末尾の改行を除去する
14
+ return formatted_text.rstrip("\n")
@@ -0,0 +1,45 @@
1
+ import json
2
+
3
+ from linebot.v3.messaging import FlexMessage
4
+ from mistletoe.block_token import Document
5
+
6
+ from md2lineflex.flex_message_renderer import FlexMessageRenderer, LinkMode
7
+ from md2lineflex.llm_utils import clean_llm_output
8
+
9
+
10
+ def to_flex(md_text: str, link_mode: LinkMode = "button") -> FlexMessage:
11
+ """
12
+ 統合型 Markdown -> LINE SDK FlexMessage 変換関数
13
+
14
+ 1. Markdown text から FlexMessage オブジェクトを生成
15
+ 2. LINE Bot SDK で送信
16
+ (flex_msg は linebot.v3.messaging.FlexMessage と互換性があります)
17
+
18
+ Example:
19
+ ```python
20
+ flex_msg = to_flex(md_text)
21
+ messaging_api.push_message(
22
+ PushMessageRequest(
23
+ to="USER_ID",
24
+ messages=[flex_msg]
25
+ )
26
+ )
27
+ ```
28
+ """
29
+ alt_text = clean_llm_output(md_text)
30
+
31
+ with FlexMessageRenderer(link_mode) as renderer:
32
+ # Document 全体の Bubble 構造(JSON文字列)を取得
33
+ json_str = renderer.render(Document(md_text.splitlines(keepends=True)))
34
+ bubble_dict = json.loads(json_str)
35
+
36
+ # FlexMessage 全体を dict 形式で組み立てて from_dict で変換する
37
+ # altText フィールドは 最大 400 文字らしい
38
+ flex_message_dict = {
39
+ "type": "flex",
40
+ "altText": alt_text.strip()[:400],
41
+ "contents": bubble_dict,
42
+ }
43
+
44
+ # from_dict を使うことで Pylance の引数不足エラーを回避
45
+ return FlexMessage.from_dict(flex_message_dict)
File without changes
@@ -0,0 +1,6 @@
1
+ import json
2
+
3
+
4
+ def to_json_utf8(data: dict | list[dict]) -> str:
5
+ """dict または list[dict] を UTF-8(日本語そのまま)の JSON 文字列に変換する"""
6
+ return json.dumps(data, ensure_ascii=False)
@@ -0,0 +1,49 @@
1
+ """LINE Flex Message 用のテーマ・デザイン定数"""
2
+
3
+ # ------------------------------------------------------------------------------
4
+ # フォールバック定数
5
+ # ------------------------------------------------------------------------------
6
+ # LINE Flex Message 仕様(text の空文字禁止)を満たすための共通フォールバック定数
7
+ EMPTY_CHAR = " "
8
+
9
+
10
+ # ------------------------------------------------------------------------------
11
+ # カラーパレット (Color Palette)
12
+ # ------------------------------------------------------------------------------
13
+ class Color:
14
+ # テキストカラー
15
+ HEADING = "#111111" # 見出し・ヘッダー用(濃い黒)
16
+ BODY = "#333333" # 本文・標準テキスト用
17
+ MUTED = "#888888" # 補足・リスト記号用
18
+ QUOTE = "#555555" # 引用テキスト用
19
+ CODE_INLINE = "#0052cc" # インラインコード用 (Jira風アクセントブルー)
20
+ CODE_BLOCK_TEXT = "#f8f8f2" # コードブロック文字色 (Monokai風)
21
+
22
+ # 背景色
23
+ BG_QUOTE = "#f8f9fa" # 引用ブロック背景色
24
+ BG_CODE_BLOCK = "#272822" # コードブロック背景色
25
+ BG_TABLE_HEADER = "#f0f0f0" # 表ヘッダー背景色
26
+ BG_TABLE_BODY = "#ffffff" # 表セル背景色
27
+ BG_TABLE_BORDER = "#e0e0e0" # 表の外枠・区切り色
28
+
29
+ # ボーダー・区切り線
30
+ BORDER = "#cccccc"
31
+ SEPARATOR = "#e0e0e0"
32
+
33
+
34
+ # ------------------------------------------------------------------------------
35
+ # レイアウト・スタイル定数
36
+ # ------------------------------------------------------------------------------
37
+ class Size:
38
+ HEADING_H1 = "xl"
39
+ HEADING_H2 = "lg"
40
+ BODY = "sm"
41
+ CODE = "xs"
42
+ TABLE_CELL = "xs"
43
+
44
+
45
+ class Margin:
46
+ HEADING = "lg"
47
+ PARAGRAPH = "md"
48
+ LIST_ITEM = "sm"
49
+ BLOCK = "md"