line-markdown-parser 0.1.4__tar.gz → 0.1.6__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.
Files changed (26) hide show
  1. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/CHANGELOG.md +15 -0
  2. {line_markdown_parser-0.1.4/line_markdown_parser.egg-info → line_markdown_parser-0.1.6}/PKG-INFO +10 -9
  3. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/README-ja.md +5 -5
  4. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/README.md +5 -5
  5. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/docs/spec.md +7 -3
  6. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/line_markdown_parser/__init__.py +1 -1
  7. line_markdown_parser-0.1.6/line_markdown_parser/_blocks.py +337 -0
  8. line_markdown_parser-0.1.6/line_markdown_parser/_flex.py +74 -0
  9. line_markdown_parser-0.1.6/line_markdown_parser/_inline.py +408 -0
  10. line_markdown_parser-0.1.6/line_markdown_parser/_inline_renderer.py +210 -0
  11. line_markdown_parser-0.1.6/line_markdown_parser/_renderer.py +288 -0
  12. line_markdown_parser-0.1.6/line_markdown_parser/_syntax.py +239 -0
  13. line_markdown_parser-0.1.6/line_markdown_parser/_uri.py +51 -0
  14. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/line_markdown_parser/converter.py +105 -31
  15. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6/line_markdown_parser.egg-info}/PKG-INFO +10 -9
  16. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/line_markdown_parser.egg-info/SOURCES.txt +6 -0
  17. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/line_markdown_parser.egg-info/requires.txt +1 -0
  18. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/pyproject.toml +10 -5
  19. line_markdown_parser-0.1.4/line_markdown_parser/_renderer.py +0 -1565
  20. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/LICENSE +0 -0
  21. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/MANIFEST.in +0 -0
  22. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/_build_backend.py +0 -0
  23. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/line_markdown_parser/py.typed +0 -0
  24. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/line_markdown_parser.egg-info/dependency_links.txt +0 -0
  25. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/line_markdown_parser.egg-info/top_level.txt +0 -0
  26. {line_markdown_parser-0.1.4 → line_markdown_parser-0.1.6}/setup.cfg +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.6 — 2026-10-23
4
+
5
+ - Balance standalone bubble insets by removing the first block's leading margin, including after long-message splitting. Keep spacing between blocks and raw body-component output unchanged.
6
+
7
+ ## 0.1.5 — 2026-10-22
8
+
9
+ - Avoid repeated suffix scans for unclosed inline HTML emphasis tags.
10
+ - Make heading links clickable through shared inline parsing.
11
+ - Keep multiline emphasis intact when an inline code span contains delimiter characters.
12
+ - Preserve adjacent links and links following multiline inline code; share code-fence body parsing with the splitter.
13
+ - Separate syntax, URI policy, and Flex layout; reuse candidate layouts within each conversion.
14
+ - Bound cached source strings as well as layouts, and preserve empty numbered markers and their indented continuations.
15
+ - Add strict type checking, cross-context and output-structure tests, and installed-wheel contract checks.
16
+ - GitHubアカウント名変更に伴いプロジェクトURLを更新(#10)。
17
+
3
18
  ## 0.1.4 — 2026-10-18
4
19
 
5
20
  - Clarify the conversion logic's use in a running LINE bot and integration guidance.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: line-markdown-parser
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Convert LLM Markdown into readable LINE Flex Messages without runtime dependencies.
5
5
  Author: line-markdown-parser contributors
6
6
  License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/darkgaldragon/line-markdown-parser
8
- Project-URL: Source, https://github.com/darkgaldragon/line-markdown-parser
9
- Project-URL: Issues, https://github.com/darkgaldragon/line-markdown-parser/issues
7
+ Project-URL: Homepage, https://github.com/darkgaldoggo/line-markdown-parser
8
+ Project-URL: Source, https://github.com/darkgaldoggo/line-markdown-parser
9
+ Project-URL: Issues, https://github.com/darkgaldoggo/line-markdown-parser/issues
10
10
  Keywords: line,markdown,flex-message,llm,chatbot
11
11
  Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Intended Audience :: Developers
@@ -23,6 +23,7 @@ License-File: LICENSE
23
23
  Provides-Extra: dev
24
24
  Requires-Dist: pytest>=8; extra == "dev"
25
25
  Requires-Dist: ruff>=0.11; extra == "dev"
26
+ Requires-Dist: mypy>=1.15; extra == "dev"
26
27
  Requires-Dist: build>=1.2; extra == "dev"
27
28
  Requires-Dist: twine>=6; extra == "dev"
28
29
  Dynamic: license-file
@@ -31,11 +32,11 @@ Dynamic: license-file
31
32
 
32
33
  Convert LLM Markdown into readable LINE Flex Message dictionaries with no runtime dependencies.
33
34
 
34
- [日本語](https://github.com/darkgaldragon/line-markdown-parser/blob/main/README-ja.md)
35
+ [日本語](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/README-ja.md)
35
36
 
36
37
  The library builds on conversion logic refined through ongoing use in a LINE bot.
37
38
 
38
- Headings, paragraphs, lists, quotes, code, and links receive a chat-friendly layout. Long output is split into separate messages. Sending remains the responsibility of your application.
39
+ Headings, paragraphs, lists, quotes, code, and links receive a chat-friendly layout. Long output is split into separate messages. Links also work inside headings, quotes, and list items. Sending remains the responsibility of your application.
39
40
 
40
41
  ## Installation
41
42
 
@@ -80,7 +81,7 @@ All functions are exported from `line_markdown_parser`.
80
81
  | `split_long_text` | Return Markdown chunks as a `list[str]`. |
81
82
  | `estimate_flex_payload_size` | Measure the rendered bubble's compact UTF-8 JSON size. |
82
83
 
83
- The converter defaults to 1,600 source characters and 28,000 rendered bytes per chunk. See the [API specification](https://github.com/darkgaldragon/line-markdown-parser/blob/main/docs/spec.md) for signatures, limits, alternative text, and error behavior.
84
+ The converter defaults to 1,600 source characters and 28,000 rendered bytes per chunk. See the [API specification](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/docs/spec.md) for signatures, limits, alternative text, and error behavior.
84
85
 
85
86
  ## Limitations
86
87
 
@@ -92,8 +93,8 @@ The converter defaults to 1,600 source characters and 28,000 rendered bytes per
92
93
 
93
94
  ## Contributing
94
95
 
95
- See [CONTRIBUTING.md](https://github.com/darkgaldragon/line-markdown-parser/blob/main/CONTRIBUTING.md) for setup and checks, [SECURITY.md](https://github.com/darkgaldragon/line-markdown-parser/blob/main/SECURITY.md) for vulnerability reporting, and [CHANGELOG.md](https://github.com/darkgaldragon/line-markdown-parser/blob/main/CHANGELOG.md) for release history.
96
+ See [CONTRIBUTING.md](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/CONTRIBUTING.md) for setup and checks, [SECURITY.md](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/SECURITY.md) for vulnerability reporting, and [CHANGELOG.md](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/CHANGELOG.md) for release history.
96
97
 
97
98
  ## License
98
99
 
99
- [MIT](https://github.com/darkgaldragon/line-markdown-parser/blob/main/LICENSE). This independent project is not an official LINE product.
100
+ [MIT](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/LICENSE). This independent project is not an official LINE product.
@@ -2,11 +2,11 @@
2
2
 
3
3
  LLMが生成したMarkdownを、LINEで読みやすいFlex Messageの辞書に変換するPythonライブラリです。実行時の外部依存はありません。
4
4
 
5
- [English](https://github.com/darkgaldragon/line-markdown-parser/blob/main/README.md)
5
+ [English](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/README.md)
6
6
 
7
7
  LINE BOTでの実運用を通じて改善してきた変換処理をもとにしています。
8
8
 
9
- 見出し、段落、リスト、引用、コード、リンクをチャットに適したレイアウトに変換し、長文を複数メッセージに分割します。送信処理はアプリケーション側で実装してください。
9
+ 見出し、段落、リスト、引用、コード、リンクをチャットに適したレイアウトに変換し、長文を複数メッセージに分割します。見出し・引用・リスト内のリンクにも対応します。送信処理はアプリケーション側で実装してください。
10
10
 
11
11
  ## インストール
12
12
 
@@ -51,7 +51,7 @@ print(json.dumps(messages, ensure_ascii=False, indent=2))
51
51
  | `split_long_text` | 分割したMarkdownを `list[str]` で返す。 |
52
52
  | `estimate_flex_payload_size` | 生成したbubbleをコンパクトなJSONにしたときのUTF-8バイト数を返す。 |
53
53
 
54
- 既定では、1チャンクあたり元テキスト1,600文字、bubbleのJSONサイズ28,000バイトを上限に分割します。引数、上限値、代替テキスト、エラー条件は[API仕様書](https://github.com/darkgaldragon/line-markdown-parser/blob/main/docs/spec.md)を参照してください。
54
+ 既定では、1チャンクあたり元テキスト1,600文字、bubbleのJSONサイズ28,000バイトを上限に分割します。引数、上限値、代替テキスト、エラー条件は[API仕様書](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/docs/spec.md)を参照してください。
55
55
 
56
56
  ## 制約
57
57
 
@@ -63,8 +63,8 @@ print(json.dumps(messages, ensure_ascii=False, indent=2))
63
63
 
64
64
  ## 開発への参加
65
65
 
66
- 開発環境と確認コマンドは [CONTRIBUTING.md](https://github.com/darkgaldragon/line-markdown-parser/blob/main/CONTRIBUTING.md)、脆弱性報告は [SECURITY.md](https://github.com/darkgaldragon/line-markdown-parser/blob/main/SECURITY.md)、変更履歴は [CHANGELOG.md](https://github.com/darkgaldragon/line-markdown-parser/blob/main/CHANGELOG.md) を参照してください。
66
+ 開発環境と確認コマンドは [CONTRIBUTING.md](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/CONTRIBUTING.md)、脆弱性報告は [SECURITY.md](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/SECURITY.md)、変更履歴は [CHANGELOG.md](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/CHANGELOG.md) を参照してください。
67
67
 
68
68
  ## ライセンス
69
69
 
70
- [MIT](https://github.com/darkgaldragon/line-markdown-parser/blob/main/LICENSE)。本プロジェクトは独立したOSSであり、LINEの公式製品ではありません。
70
+ [MIT](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/LICENSE)。本プロジェクトは独立したOSSであり、LINEの公式製品ではありません。
@@ -2,11 +2,11 @@
2
2
 
3
3
  Convert LLM Markdown into readable LINE Flex Message dictionaries with no runtime dependencies.
4
4
 
5
- [日本語](https://github.com/darkgaldragon/line-markdown-parser/blob/main/README-ja.md)
5
+ [日本語](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/README-ja.md)
6
6
 
7
7
  The library builds on conversion logic refined through ongoing use in a LINE bot.
8
8
 
9
- Headings, paragraphs, lists, quotes, code, and links receive a chat-friendly layout. Long output is split into separate messages. Sending remains the responsibility of your application.
9
+ Headings, paragraphs, lists, quotes, code, and links receive a chat-friendly layout. Long output is split into separate messages. Links also work inside headings, quotes, and list items. Sending remains the responsibility of your application.
10
10
 
11
11
  ## Installation
12
12
 
@@ -51,7 +51,7 @@ All functions are exported from `line_markdown_parser`.
51
51
  | `split_long_text` | Return Markdown chunks as a `list[str]`. |
52
52
  | `estimate_flex_payload_size` | Measure the rendered bubble's compact UTF-8 JSON size. |
53
53
 
54
- The converter defaults to 1,600 source characters and 28,000 rendered bytes per chunk. See the [API specification](https://github.com/darkgaldragon/line-markdown-parser/blob/main/docs/spec.md) for signatures, limits, alternative text, and error behavior.
54
+ The converter defaults to 1,600 source characters and 28,000 rendered bytes per chunk. See the [API specification](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/docs/spec.md) for signatures, limits, alternative text, and error behavior.
55
55
 
56
56
  ## Limitations
57
57
 
@@ -63,8 +63,8 @@ The converter defaults to 1,600 source characters and 28,000 rendered bytes per
63
63
 
64
64
  ## Contributing
65
65
 
66
- See [CONTRIBUTING.md](https://github.com/darkgaldragon/line-markdown-parser/blob/main/CONTRIBUTING.md) for setup and checks, [SECURITY.md](https://github.com/darkgaldragon/line-markdown-parser/blob/main/SECURITY.md) for vulnerability reporting, and [CHANGELOG.md](https://github.com/darkgaldragon/line-markdown-parser/blob/main/CHANGELOG.md) for release history.
66
+ See [CONTRIBUTING.md](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/CONTRIBUTING.md) for setup and checks, [SECURITY.md](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/SECURITY.md) for vulnerability reporting, and [CHANGELOG.md](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/CHANGELOG.md) for release history.
67
67
 
68
68
  ## License
69
69
 
70
- [MIT](https://github.com/darkgaldragon/line-markdown-parser/blob/main/LICENSE). This independent project is not an official LINE product.
70
+ [MIT](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/LICENSE). This independent project is not an official LINE product.
@@ -1,6 +1,6 @@
1
1
  # API specification
2
2
 
3
- `line-markdown-parser` converts Markdown strings into LINE Flex Message dictionaries without runtime dependencies or network requests. The API below applies to version 0.1.4.
3
+ `line-markdown-parser` converts Markdown strings into LINE Flex Message dictionaries without runtime dependencies or network requests. This document describes the current source tree; see the changelog for released versions.
4
4
 
5
5
  ## Functions
6
6
 
@@ -29,7 +29,9 @@ estimate_flex_payload_size(text) -> int
29
29
  | `split_long_text` | Return Markdown chunks within both source-length and rendered-byte limits. Empty or whitespace-only input returns `[]` after argument validation. |
30
30
  | `estimate_flex_payload_size` | Return the rendered bubble's compact UTF-8 JSON size, including for empty input. |
31
31
 
32
- Visual properties and exact component nesting may change without changing this contract.
32
+ Every call returns fresh mutable dictionaries; modifying one message does not affect other messages or later calls. Candidate layouts may be reused within a call, but input and output are not cached across calls. Visual properties and exact component nesting may change without changing this contract.
33
+
34
+ Standalone bubbles use equal outer padding and no leading margin on their first body component. Spacing between blocks is preserved. `build_flex_contents` returns block margins unchanged so applications can compose their own containers.
33
35
 
34
36
  ## Limits and splitting
35
37
 
@@ -64,9 +66,11 @@ All text arguments must be strings; other types raise `TypeError`. Every public
64
66
  | Links | Labels and supported URI actions. |
65
67
  | Images | Labels and links; no fetching or image components. |
66
68
 
69
+ Paragraphs, headings, quotes, list items, and emoji-leading lines share inline parsing. For example, `# [Guide](https://example.com)` produces a heading with a URI action. Links occupy separate text components so each destination is clickable. Link labels retain the `🔗 ` prefix and are shortened to 40 characters; the action keeps the full destination.
70
+
67
71
  Full CommonMark conformance, HTML rendering, arbitrary nesting, and exact browser-style layout are outside the scope. Malformed input or unusual fences may render as ordinary text or lose formatting. Table alignment depends on client fonts and display width.
68
72
 
69
- Only `http` and `https` receive [URI actions](https://developers.line.biz/en/reference/messaging-api/#uri-action). Unsupported schemes retain their labels without an action. Malformed HTTP(S) destinations and URLs exceeding the action URI length limit remain text. Existing percent escapes and URL delimiters are preserved when encoding destinations. Destinations are not verified.
73
+ Only `http` and `https` receive [URI actions](https://developers.line.biz/en/reference/messaging-api/#uri-action). Unsupported schemes retain their labels without an action. Malformed HTTP(S) destinations and URLs exceeding the action URI length limit remain text. Existing percent escapes and URL delimiters are preserved when encoding destinations. Destinations are not verified. Lowercase `http://` and `https://` with ASCII hostnames are supported; uppercase schemes, IPv6 hosts, and title-bearing links such as `[Guide](https://example.com "title")` are outside the supported subset.
70
74
 
71
75
  ## Integration
72
76
 
@@ -9,7 +9,7 @@ from .converter import (
9
9
  split_long_text,
10
10
  )
11
11
 
12
- __version__ = "0.1.4"
12
+ __version__ = "0.1.6"
13
13
  __all__ = [
14
14
  "DEFAULT_MAX_FLEX_PAYLOAD_BYTES",
15
15
  "build_flex_contents",
@@ -0,0 +1,337 @@
1
+ """Block recognition for the supported Markdown subset; no Flex generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+ from ._inline import (
10
+ _find_closing_inline_delimiter,
11
+ _find_unclosed_inline_delimiter,
12
+ _transform_outside_inline_code,
13
+ )
14
+ from ._syntax import _inline_code_span_ranges, iter_fenced_segments, parse_code_fence
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Block:
19
+ kind: Literal[
20
+ "paragraph",
21
+ "spacer",
22
+ "code",
23
+ "table",
24
+ "quote",
25
+ "heading",
26
+ "list",
27
+ "separator",
28
+ "marker",
29
+ "indent",
30
+ ]
31
+ text: str = ""
32
+ lines: tuple[str, ...] = ()
33
+ level: int = 0
34
+ bullet: str = ""
35
+ ordered: bool = False
36
+ is_task: bool = False
37
+ language: str = ""
38
+
39
+
40
+ _MARKDOWN_HARD_BREAK_RE = re.compile("( {2,}|<br\\s*/?>)\\s*$", flags=re.IGNORECASE)
41
+ _UNORDERED_LIST_MATCH_RE = re.compile(
42
+ "^(\\s*)(?:(?:[-*+‐•]\\s+)|(?:・\\s*))([\\s\\S]+)$"
43
+ )
44
+ _LIST_ITEM_MARKER_RE = re.compile(
45
+ "^\\s*(?:(?:[-*+‐•]\\s+)|(?:・\\s*)|\\d+[.)]\\s+)(?P<text>.*)$"
46
+ )
47
+ _LIST_ITEM_TITLE_CONTENT_RE = re.compile("^(\\*{1,3})(?!\\s).+?(?<!\\s)\\1(?:[::])?$")
48
+ _TABLE_BLOCK_TERMINATOR_RE = re.compile(
49
+ "^\\s*(?:#{1,6}\\s+|(?:[-*+‐•]\\s+|・\\s*)|\\d+[.)]\\s+|>)"
50
+ )
51
+
52
+
53
+ def is_char_emoji(char: str) -> bool:
54
+ """文字が絵文字の主要なUnicode範囲に含まれるか判定する。"""
55
+ if not char:
56
+ return False
57
+ code = ord(char)
58
+ return (
59
+ 127744 <= code <= 128511
60
+ or 128512 <= code <= 128591
61
+ or 128640 <= code <= 128767
62
+ or (129280 <= code <= 129535)
63
+ or (9728 <= code <= 9983)
64
+ or (9984 <= code <= 10175)
65
+ or (129648 <= code <= 129791)
66
+ )
67
+
68
+
69
+ def _ends_with_markdown_hard_break(text: str) -> bool:
70
+ """末尾行が Markdown のハード改行マーカー(末尾2スペース / <br>)で終わるか判定する。"""
71
+ last_line = text.split("\n")[-1]
72
+ if marker := _LIST_ITEM_MARKER_RE.match(last_line):
73
+ last_line = marker.group("text")
74
+ return bool(_MARKDOWN_HARD_BREAK_RE.search(last_line))
75
+
76
+
77
+ def _strip_markdown_hard_break(text: str) -> str:
78
+ """末尾行の Markdown ハード改行マーカー(末尾2スペース / <br>)を除去する。"""
79
+ lines = text.split("\n")
80
+ if not lines:
81
+ return text
82
+ lines[-1] = _MARKDOWN_HARD_BREAK_RE.sub("", lines[-1])
83
+ return "\n".join(lines)
84
+
85
+
86
+ def _looks_like_list_item_title_line(text: str) -> bool:
87
+ """リストの末尾行がタイトルとみなせる場合にTrueを返す(改行として扱う)。"""
88
+ last_line = text.split("\n")[-1].rstrip()
89
+ match = _LIST_ITEM_MARKER_RE.match(last_line)
90
+ if not match:
91
+ return False
92
+ item_text = (match.group("text") or "").strip()
93
+ if not item_text:
94
+ return False
95
+ if item_text.endswith((":", ":")):
96
+ return True
97
+ return bool(_LIST_ITEM_TITLE_CONTENT_RE.fullmatch(item_text))
98
+
99
+
100
+ def _allows_multiline_inline_continuation(line: str) -> bool:
101
+ stripped = line.strip()
102
+ if not stripped:
103
+ return False
104
+ if stripped.startswith(">"):
105
+ return False
106
+ if re.match("^(#{1,6})\\s+(.*)", stripped):
107
+ return False
108
+ if re.match("^(\\s*)(?:[-‐]{3,}|\\*{3,}|_{3,})\\s*$", stripped):
109
+ return False
110
+ if _UNORDERED_LIST_MATCH_RE.match(line):
111
+ return False
112
+ if _ORDERED_LIST_START_RE.match(line):
113
+ return False
114
+ if re.match("^\\s+.+", line):
115
+ return False
116
+ return True
117
+
118
+
119
+ def _collect_multiline_inline_block(
120
+ lines: list[str], start_index: int, closing_line_cache: dict[str, list[int | None]]
121
+ ) -> tuple[str, int] | None:
122
+ start_line = lines[start_index].strip()
123
+ opener = _find_unclosed_inline_delimiter(start_line)
124
+ if opener is None:
125
+ return None
126
+ delimiter, opener_index = opener
127
+ if (
128
+ _find_closing_inline_delimiter(
129
+ start_line,
130
+ opener_index + len(delimiter),
131
+ delimiter,
132
+ _inline_code_span_ranges(start_line),
133
+ )
134
+ is not None
135
+ ):
136
+ return None
137
+ if delimiter not in closing_line_cache:
138
+ closing_lines: list[int | None] = [None] * len(lines)
139
+ next_closing_line: int | None = None
140
+ for index in range(len(lines) - 1, -1, -1):
141
+ closing_lines[index] = next_closing_line
142
+ line = lines[index]
143
+ if not _allows_multiline_inline_continuation(line):
144
+ next_closing_line = None
145
+ elif (
146
+ _find_closing_inline_delimiter(
147
+ line.strip(), 0, delimiter, _inline_code_span_ranges(line.strip())
148
+ )
149
+ is not None
150
+ ):
151
+ next_closing_line = index
152
+ closing_line_cache[delimiter] = closing_lines
153
+ closing_line = closing_line_cache[delimiter][start_index]
154
+ if closing_line is None:
155
+ return None
156
+ joined = "\n".join(line.strip() for line in lines[start_index : closing_line + 1])
157
+ return (joined, closing_line + 1)
158
+
159
+
160
+ def _is_markdown_table_separator(line: str) -> bool:
161
+ stripped = line.strip()
162
+ if "|" not in stripped:
163
+ return False
164
+ cells = [cell.strip() for cell in stripped.strip("|").split("|")]
165
+ if len(cells) < 2:
166
+ return False
167
+ return all(re.fullmatch(":?-{3,}:?", cell or "") for cell in cells)
168
+
169
+
170
+ def _looks_like_markdown_table_start(lines: list[str], index: int) -> bool:
171
+ if index + 1 >= len(lines):
172
+ return False
173
+ header = lines[index].strip()
174
+ if "|" not in header or header.count("|") < 1:
175
+ return False
176
+ return _is_markdown_table_separator(lines[index + 1])
177
+
178
+
179
+ def _task_list_prefix(raw_list_item_text: str) -> tuple[str, str]:
180
+ task_match = re.match(
181
+ "^\\[(?P<mark>[ xX])\\]\\s+(?P<text>[\\s\\S]+)$", raw_list_item_text
182
+ )
183
+ if not task_match:
184
+ return ("", raw_list_item_text)
185
+ marker = "☑ " if task_match.group("mark").lower() == "x" else "☐ "
186
+ return (marker, task_match.group("text").strip())
187
+
188
+
189
+ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)")
190
+ _SEPARATOR_RE = re.compile(r"^(\s*)(?:[-‐]{3,}|\*{3,}|_{3,})\s*$")
191
+ _ORDERED_LIST_START_RE = re.compile(r"^(\s*)(\d+)([.)])\s+(.*)")
192
+ _ORDERED_LIST_RE = re.compile(r"^(\s*)(\d+)([.)])\s+(\S[\s\S]*)")
193
+
194
+
195
+ def _normalize_lines(segment: str) -> list[str]:
196
+ segment = "\n".join(
197
+ _transform_outside_inline_code(
198
+ line, lambda source: re.sub(r"<br\s*/?>", "\n", source, flags=re.IGNORECASE)
199
+ )
200
+ for line in segment.split("\n")
201
+ )
202
+ normalized: list[str] = []
203
+ prev_was_list = False
204
+ for line in segment.strip().split("\n"):
205
+ stripped = line.strip()
206
+ separator = _SEPARATOR_RE.match(stripped)
207
+ is_list = bool(
208
+ (_UNORDERED_LIST_MATCH_RE.match(line) if not separator else None)
209
+ or _ORDERED_LIST_START_RE.match(line)
210
+ )
211
+ if (
212
+ not is_list
213
+ and prev_was_list
214
+ and stripped
215
+ and line[:1].isspace()
216
+ and not _HEADING_RE.match(stripped)
217
+ and not separator
218
+ ):
219
+ continuation = line.lstrip()
220
+ previous = normalized[-1]
221
+ if _ends_with_markdown_hard_break(
222
+ previous
223
+ ) or _looks_like_list_item_title_line(previous):
224
+ normalized[-1] = (
225
+ _strip_markdown_hard_break(previous).rstrip() + "\n" + continuation
226
+ )
227
+ else:
228
+ normalized[-1] = previous.rstrip() + " " + continuation
229
+ continue
230
+ normalized.append(line)
231
+ prev_was_list = is_list
232
+ return normalized
233
+
234
+
235
+ def _parse_lines(lines: list[str]) -> list[Block]:
236
+ blocks: list[Block] = []
237
+ paragraph: list[str] = []
238
+ closing_cache: dict[str, list[int | None]] = {}
239
+
240
+ def flush_paragraph() -> None:
241
+ if paragraph:
242
+ blocks.append(Block("paragraph", lines=tuple(paragraph)))
243
+ paragraph.clear()
244
+
245
+ index = 0
246
+ while index < len(lines):
247
+ line = lines[index]
248
+ stripped = line.strip()
249
+ heading = _HEADING_RE.match(stripped)
250
+ separator = _SEPARATOR_RE.match(stripped)
251
+ unordered = _UNORDERED_LIST_MATCH_RE.match(line) if not separator else None
252
+ ordered = _ORDERED_LIST_RE.match(line)
253
+ if not stripped:
254
+ flush_paragraph()
255
+ blocks.append(Block("spacer"))
256
+ elif _looks_like_markdown_table_start(lines, index):
257
+ flush_paragraph()
258
+ start = index
259
+ index += 2
260
+ while (
261
+ index < len(lines)
262
+ and lines[index].strip()
263
+ and "|" in lines[index]
264
+ and not _TABLE_BLOCK_TERMINATOR_RE.match(lines[index])
265
+ ):
266
+ index += 1
267
+ blocks.append(Block("table", lines=tuple(lines[start:index])))
268
+ continue
269
+ elif stripped.startswith(">"):
270
+ flush_paragraph()
271
+ quote: list[str] = []
272
+ while index < len(lines) and lines[index].strip().startswith(">"):
273
+ quote.append(re.sub(r"^>\s?", "", lines[index].strip()))
274
+ index += 1
275
+ blocks.append(Block("quote", text="\n".join(quote).strip()))
276
+ continue
277
+ elif heading:
278
+ flush_paragraph()
279
+ blocks.append(
280
+ Block("heading", text=heading[2].strip(), level=len(heading[1]))
281
+ )
282
+ elif ordered or unordered:
283
+ flush_paragraph()
284
+ match = ordered or unordered
285
+ assert match is not None
286
+ text = match[4].strip() if ordered else match[2].strip()
287
+ task_prefix, text = _task_list_prefix(text)
288
+ bullet = f"{match[2]}{match[3]} " if ordered else "• "
289
+ blocks.append(
290
+ Block(
291
+ "list",
292
+ text=text,
293
+ level=len(match[1]),
294
+ bullet=task_prefix or bullet,
295
+ ordered=bool(ordered),
296
+ is_task=bool(task_prefix),
297
+ )
298
+ )
299
+ elif separator:
300
+ flush_paragraph()
301
+ blocks.append(Block("separator"))
302
+ elif is_char_emoji(stripped[0]):
303
+ flush_paragraph()
304
+ multiline = _collect_multiline_inline_block(lines, index, closing_cache)
305
+ if multiline:
306
+ text, index = multiline
307
+ blocks.append(Block("marker", text=text))
308
+ continue
309
+ blocks.append(Block("marker", text=stripped))
310
+ elif indent := re.match(r"^(\s+)(.+)", line):
311
+ flush_paragraph()
312
+ blocks.append(Block("indent", text=indent[2].strip(), level=len(indent[1])))
313
+ else:
314
+ multiline = _collect_multiline_inline_block(lines, index, closing_cache)
315
+ if multiline:
316
+ text, index = multiline
317
+ paragraph.append(text)
318
+ continue
319
+ paragraph.append(stripped)
320
+ index += 1
321
+ flush_paragraph()
322
+ return blocks
323
+
324
+
325
+ def parse_blocks(text: str) -> list[Block]:
326
+ blocks: list[Block] = []
327
+ for segment in iter_fenced_segments(text):
328
+ if not segment.text.strip():
329
+ continue
330
+ if not segment.is_code:
331
+ blocks.extend(_parse_lines(_normalize_lines(segment.text)))
332
+ continue
333
+ fence = parse_code_fence(segment.text)
334
+ blocks.append(
335
+ Block("code", text=fence.body.rstrip("\n"), language=fence.language)
336
+ )
337
+ return blocks
@@ -0,0 +1,74 @@
1
+ """Types for the subset of LINE Flex components emitted by this library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal, TypeAlias, TypedDict
6
+
7
+
8
+ class _SpanRequired(TypedDict):
9
+ type: Literal["span"]
10
+ text: str
11
+
12
+
13
+ class FlexSpan(_SpanRequired, total=False):
14
+ color: str
15
+ size: str
16
+ weight: Literal["regular", "bold"]
17
+ style: Literal["normal", "italic"]
18
+ decoration: Literal["none", "underline", "line-through"]
19
+
20
+
21
+ class URIAction(TypedDict):
22
+ type: Literal["uri"]
23
+ uri: str
24
+
25
+
26
+ class _TextRequired(TypedDict):
27
+ type: Literal["text"]
28
+
29
+
30
+ class FlexText(_TextRequired, total=False):
31
+ text: str
32
+ contents: list[FlexSpan]
33
+ wrap: bool
34
+ margin: str
35
+ size: str
36
+ weight: Literal["regular", "bold"]
37
+ color: str
38
+ align: Literal["start", "end", "center"]
39
+ gravity: Literal["top", "bottom", "center"]
40
+ flex: int
41
+ action: URIAction
42
+
43
+
44
+ class _BoxRequired(TypedDict):
45
+ type: Literal["box"]
46
+ layout: Literal["vertical", "horizontal", "baseline"]
47
+ contents: list[FlexComponent]
48
+
49
+
50
+ class FlexBox(_BoxRequired, total=False):
51
+ spacing: str
52
+ margin: str
53
+ paddingAll: str
54
+ paddingStart: str
55
+ backgroundColor: str
56
+ cornerRadius: str
57
+ width: str
58
+ flex: int
59
+ alignItems: Literal["flex-start", "flex-end", "center"]
60
+
61
+
62
+ FlexComponent: TypeAlias = FlexBox | FlexText
63
+
64
+
65
+ class FlexBubble(TypedDict):
66
+ type: Literal["bubble"]
67
+ size: Literal["giga"]
68
+ body: FlexBox
69
+
70
+
71
+ class FlexMessage(TypedDict):
72
+ type: Literal["flex"]
73
+ altText: str
74
+ contents: FlexBubble