line-markdown-parser 0.1.3__tar.gz → 0.1.5__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.
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/CHANGELOG.md +15 -0
- {line_markdown_parser-0.1.3/line_markdown_parser.egg-info → line_markdown_parser-0.1.5}/PKG-INFO +13 -10
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/README-ja.md +8 -6
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/README.md +8 -6
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/docs/spec.md +6 -4
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/line_markdown_parser/__init__.py +1 -1
- line_markdown_parser-0.1.5/line_markdown_parser/_blocks.py +337 -0
- line_markdown_parser-0.1.5/line_markdown_parser/_flex.py +74 -0
- line_markdown_parser-0.1.5/line_markdown_parser/_inline.py +408 -0
- line_markdown_parser-0.1.5/line_markdown_parser/_inline_renderer.py +210 -0
- line_markdown_parser-0.1.5/line_markdown_parser/_renderer.py +288 -0
- line_markdown_parser-0.1.5/line_markdown_parser/_syntax.py +239 -0
- line_markdown_parser-0.1.5/line_markdown_parser/_uri.py +51 -0
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/line_markdown_parser/converter.py +101 -31
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5/line_markdown_parser.egg-info}/PKG-INFO +13 -10
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/line_markdown_parser.egg-info/SOURCES.txt +6 -0
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/line_markdown_parser.egg-info/requires.txt +1 -0
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/pyproject.toml +10 -5
- line_markdown_parser-0.1.3/line_markdown_parser/_renderer.py +0 -1565
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/LICENSE +0 -0
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/MANIFEST.in +0 -0
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/_build_backend.py +0 -0
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/line_markdown_parser/py.typed +0 -0
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/line_markdown_parser.egg-info/dependency_links.txt +0 -0
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/line_markdown_parser.egg-info/top_level.txt +0 -0
- {line_markdown_parser-0.1.3 → line_markdown_parser-0.1.5}/setup.cfg +0 -0
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.5 — 2026-10-22
|
|
4
|
+
|
|
5
|
+
- Avoid repeated suffix scans for unclosed inline HTML emphasis tags.
|
|
6
|
+
- Make heading links clickable through shared inline parsing.
|
|
7
|
+
- Keep multiline emphasis intact when an inline code span contains delimiter characters.
|
|
8
|
+
- Preserve adjacent links and links following multiline inline code; share code-fence body parsing with the splitter.
|
|
9
|
+
- Separate syntax, URI policy, and Flex layout; reuse candidate layouts within each conversion.
|
|
10
|
+
- Bound cached source strings as well as layouts, and preserve empty numbered markers and their indented continuations.
|
|
11
|
+
- Add strict type checking, cross-context and output-structure tests, and installed-wheel contract checks.
|
|
12
|
+
- GitHubアカウント名変更に伴いプロジェクトURLを更新(#10)。
|
|
13
|
+
|
|
14
|
+
## 0.1.4 — 2026-10-18
|
|
15
|
+
|
|
16
|
+
- Clarify the conversion logic's use in a running LINE bot and integration guidance.
|
|
17
|
+
|
|
3
18
|
## 0.1.3 — 2026-10-18
|
|
4
19
|
|
|
5
20
|
- Fix documentation links in the PyPI project description.
|
{line_markdown_parser-0.1.3/line_markdown_parser.egg-info → line_markdown_parser-0.1.5}/PKG-INFO
RENAMED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: line-markdown-parser
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
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/
|
|
8
|
-
Project-URL: Source, https://github.com/
|
|
9
|
-
Project-URL: Issues, https://github.com/
|
|
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,9 +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/
|
|
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.
|
|
38
|
+
|
|
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.
|
|
37
40
|
|
|
38
41
|
## Installation
|
|
39
42
|
|
|
@@ -78,7 +81,7 @@ All functions are exported from `line_markdown_parser`.
|
|
|
78
81
|
| `split_long_text` | Return Markdown chunks as a `list[str]`. |
|
|
79
82
|
| `estimate_flex_payload_size` | Measure the rendered bubble's compact UTF-8 JSON size. |
|
|
80
83
|
|
|
81
|
-
The converter defaults to 1,600 source characters and 28,000 rendered bytes per chunk. See the [API specification](https://github.com/
|
|
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.
|
|
82
85
|
|
|
83
86
|
## Limitations
|
|
84
87
|
|
|
@@ -86,12 +89,12 @@ The converter defaults to 1,600 source characters and 28,000 rendered bytes per
|
|
|
86
89
|
- Images appear as labels and links, without fetching or embedding images.
|
|
87
90
|
- Only `http` and `https` links receive URI actions; destinations are not verified.
|
|
88
91
|
- Full CommonMark support, arbitrary nesting, and every code-fence style are outside the scope.
|
|
89
|
-
-
|
|
92
|
+
- When integrating, check the output with the [Flex Message Simulator](https://developers.line.biz/flex-simulator/) and your target LINE clients.
|
|
90
93
|
|
|
91
94
|
## Contributing
|
|
92
95
|
|
|
93
|
-
See [CONTRIBUTING.md](https://github.com/
|
|
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.
|
|
94
97
|
|
|
95
98
|
## License
|
|
96
99
|
|
|
97
|
-
[MIT](https://github.com/
|
|
100
|
+
[MIT](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/LICENSE). This independent project is not an official LINE product.
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
LLMが生成したMarkdownを、LINEで読みやすいFlex Messageの辞書に変換するPythonライブラリです。実行時の外部依存はありません。
|
|
4
4
|
|
|
5
|
-
[English](https://github.com/
|
|
5
|
+
[English](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/README.md)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
LINE BOTでの実運用を通じて改善してきた変換処理をもとにしています。
|
|
8
|
+
|
|
9
|
+
見出し、段落、リスト、引用、コード、リンクをチャットに適したレイアウトに変換し、長文を複数メッセージに分割します。見出し・引用・リスト内のリンクにも対応します。送信処理はアプリケーション側で実装してください。
|
|
8
10
|
|
|
9
11
|
## インストール
|
|
10
12
|
|
|
@@ -49,7 +51,7 @@ print(json.dumps(messages, ensure_ascii=False, indent=2))
|
|
|
49
51
|
| `split_long_text` | 分割したMarkdownを `list[str]` で返す。 |
|
|
50
52
|
| `estimate_flex_payload_size` | 生成したbubbleをコンパクトなJSONにしたときのUTF-8バイト数を返す。 |
|
|
51
53
|
|
|
52
|
-
既定では、1チャンクあたり元テキスト1,600文字、bubbleのJSONサイズ28,000バイトを上限に分割します。引数、上限値、代替テキスト、エラー条件は[API仕様書](https://github.com/
|
|
54
|
+
既定では、1チャンクあたり元テキスト1,600文字、bubbleのJSONサイズ28,000バイトを上限に分割します。引数、上限値、代替テキスト、エラー条件は[API仕様書](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/docs/spec.md)を参照してください。
|
|
53
55
|
|
|
54
56
|
## 制約
|
|
55
57
|
|
|
@@ -57,12 +59,12 @@ print(json.dumps(messages, ensure_ascii=False, indent=2))
|
|
|
57
59
|
- 画像はラベルとリンクで表示し、取得や埋め込みは行いません。
|
|
58
60
|
- URIアクションは `http` と `https` のみに対応します。リンク先の検証は行いません。
|
|
59
61
|
- 完全なCommonMark互換と任意のネストには対応していません。コードフェンスもすべての形式に対応しているわけではありません。
|
|
60
|
-
-
|
|
62
|
+
- 導入時は、[Flex Message Simulator](https://developers.line.biz/flex-simulator/)と利用するLINEアプリで表示を確認してください。
|
|
61
63
|
|
|
62
64
|
## 開発への参加
|
|
63
65
|
|
|
64
|
-
開発環境と確認コマンドは [CONTRIBUTING.md](https://github.com/
|
|
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) を参照してください。
|
|
65
67
|
|
|
66
68
|
## ライセンス
|
|
67
69
|
|
|
68
|
-
[MIT](https://github.com/
|
|
70
|
+
[MIT](https://github.com/darkgaldoggo/line-markdown-parser/blob/main/LICENSE)。本プロジェクトは独立したOSSであり、LINEの公式製品ではありません。
|
|
@@ -2,9 +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/
|
|
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
|
+
|
|
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.
|
|
8
10
|
|
|
9
11
|
## Installation
|
|
10
12
|
|
|
@@ -49,7 +51,7 @@ All functions are exported from `line_markdown_parser`.
|
|
|
49
51
|
| `split_long_text` | Return Markdown chunks as a `list[str]`. |
|
|
50
52
|
| `estimate_flex_payload_size` | Measure the rendered bubble's compact UTF-8 JSON size. |
|
|
51
53
|
|
|
52
|
-
The converter defaults to 1,600 source characters and 28,000 rendered bytes per chunk. See the [API specification](https://github.com/
|
|
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.
|
|
53
55
|
|
|
54
56
|
## Limitations
|
|
55
57
|
|
|
@@ -57,12 +59,12 @@ The converter defaults to 1,600 source characters and 28,000 rendered bytes per
|
|
|
57
59
|
- Images appear as labels and links, without fetching or embedding images.
|
|
58
60
|
- Only `http` and `https` links receive URI actions; destinations are not verified.
|
|
59
61
|
- Full CommonMark support, arbitrary nesting, and every code-fence style are outside the scope.
|
|
60
|
-
-
|
|
62
|
+
- When integrating, check the output with the [Flex Message Simulator](https://developers.line.biz/flex-simulator/) and your target LINE clients.
|
|
61
63
|
|
|
62
64
|
## Contributing
|
|
63
65
|
|
|
64
|
-
See [CONTRIBUTING.md](https://github.com/
|
|
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.
|
|
65
67
|
|
|
66
68
|
## License
|
|
67
69
|
|
|
68
|
-
[MIT](https://github.com/
|
|
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.
|
|
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,7 @@ 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
33
|
|
|
34
34
|
## Limits and splitting
|
|
35
35
|
|
|
@@ -64,12 +64,14 @@ All text arguments must be strings; other types raise `TypeError`. Every public
|
|
|
64
64
|
| Links | Labels and supported URI actions. |
|
|
65
65
|
| Images | Labels and links; no fetching or image components. |
|
|
66
66
|
|
|
67
|
+
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.
|
|
68
|
+
|
|
67
69
|
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
70
|
|
|
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.
|
|
71
|
+
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
72
|
|
|
71
73
|
## Integration
|
|
72
74
|
|
|
73
75
|
The converter returns all messages without sending or truncating them. LINE [reply](https://developers.line.biz/en/reference/messaging-api/#send-reply-message) and [push](https://developers.line.biz/en/reference/messaging-api/#send-push-message) requests accept at most five messages. Applications handle batching, reply-token restrictions, authentication, recipients, and retries.
|
|
74
76
|
|
|
75
|
-
|
|
77
|
+
Validate integrations using the [Flex Message Simulator](https://developers.line.biz/flex-simulator/), [message validation endpoints](https://developers.line.biz/en/reference/messaging-api/#message-objects), and target LINE clients.
|
|
@@ -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
|