slack-markdown-parser 2.3.0__tar.gz → 2.3.2__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 (18) hide show
  1. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/CHANGELOG.md +14 -0
  2. {slack_markdown_parser-2.3.0/slack_markdown_parser.egg-info → slack_markdown_parser-2.3.2}/PKG-INFO +4 -4
  3. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/README-ja.md +1 -1
  4. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/README.md +3 -2
  5. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/docs/spec-ja.md +1 -0
  6. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/docs/spec.md +1 -0
  7. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/pyproject.toml +5 -3
  8. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/slack_markdown_parser/__init__.py +1 -1
  9. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/slack_markdown_parser/converter.py +270 -82
  10. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2/slack_markdown_parser.egg-info}/PKG-INFO +4 -4
  11. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/slack_markdown_parser.egg-info/requires.txt +0 -1
  12. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/LICENSE +0 -0
  13. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/MANIFEST.in +0 -0
  14. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/setup.cfg +0 -0
  15. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/slack_markdown_parser/py.typed +0 -0
  16. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/slack_markdown_parser.egg-info/SOURCES.txt +0 -0
  17. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/slack_markdown_parser.egg-info/dependency_links.txt +0 -0
  18. {slack_markdown_parser-2.3.0 → slack_markdown_parser-2.3.2}/slack_markdown_parser.egg-info/top_level.txt +0 -0
@@ -6,6 +6,20 @@ The format is based on Keep a Changelog, and the project follows Semantic Versio
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.3.2] - 2026-04-17
10
+
11
+ ### Fixed
12
+
13
+ - Stopped `preserve_visual_blank_lines` from keeping ordered-list context open after list items, including continued items, nested lists, and ordered-list paragraph interruption edge cases.
14
+ - Avoided false-positive list-context detection for indented non-list lines and thematic breaks, so visible blank-line preservation no longer regresses on inputs like ` 1. not-a-list` or `- - -`.
15
+
16
+ ## [2.3.1] - 2026-04-10
17
+
18
+ ### Fixed
19
+
20
+ - Stopped `preserve_visual_blank_lines` from inserting visual-only blank-line placeholders inside fenced code blocks.
21
+ - Kept fallback plain text stable when visual blank-line placeholders are enabled alongside parser-added spacing markers around emphasis or inline code.
22
+
9
23
  ## [2.3.0] - 2026-04-10
10
24
 
11
25
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-markdown-parser
3
- Version: 2.3.0
3
+ Version: 2.3.2
4
4
  Summary: Convert LLM Markdown into Slack Block Kit markdown/table messages
5
5
  Author: darkgaldragon
6
6
  License-Expression: MIT
@@ -26,7 +26,6 @@ Requires-Dist: pip-audit>=2.7.0; extra == "dev"
26
26
  Requires-Dist: pytest>=8.0.0; extra == "dev"
27
27
  Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
28
28
  Requires-Dist: ruff>=0.6.0; extra == "dev"
29
- Requires-Dist: twine>=5.1.0; extra == "dev"
30
29
  Dynamic: license-file
31
30
 
32
31
  # slack-markdown-parser
@@ -210,8 +209,9 @@ Markdown segments with lines that contain only a non-breaking space. Those
210
209
  placeholder lines are removed again when generating preview plain text, so Slack
211
210
  notifications and logs stay close to the original Markdown source.
212
211
  The current implementation deliberately skips blank runs that sit immediately
213
- before setext-heading underlines or reference-link definitions, because those
214
- boundaries can change Markdown meaning in newer Slack Markdown rendering.
212
+ after list-item content, before setext-heading underlines, or before
213
+ reference-link definitions, because those boundaries can change Markdown meaning in newer
214
+ Slack Markdown rendering or keep list formatting open in some clients.
215
215
 
216
216
  ### Utility functions
217
217
 
@@ -161,7 +161,7 @@ QA | ~~保留~~ | Team C
161
161
 
162
162
  `preserve_visual_blank_lines=True` は、非テーブル領域の内部空行を「改行を見えやすくするための補助行」に置き換えるオプションです。
163
163
  この補助行はプレビュー文字列を作るときに取り除かれるので、通知文やログは元の Markdown に近い形を保てます。
164
- また、setext 見出しの下線直前や参照リンク定義直前には補助行を入れず、Markdown の意味が変わらないようにしています。
164
+ また、リスト項目の内容直後、setext 見出しの下線直前、参照リンク定義直前には補助行を入れず、Markdown の意味が変わったり一部クライアントでリスト解釈が継続したりしないようにしています。
165
165
 
166
166
  ### ユーティリティ関数(公開関数)
167
167
 
@@ -179,8 +179,9 @@ Markdown segments with lines that contain only a non-breaking space. Those
179
179
  placeholder lines are removed again when generating preview plain text, so Slack
180
180
  notifications and logs stay close to the original Markdown source.
181
181
  The current implementation deliberately skips blank runs that sit immediately
182
- before setext-heading underlines or reference-link definitions, because those
183
- boundaries can change Markdown meaning in newer Slack Markdown rendering.
182
+ after list-item content, before setext-heading underlines, or before
183
+ reference-link definitions, because those boundaries can change Markdown meaning in newer
184
+ Slack Markdown rendering or keep list formatting open in some clients.
184
185
 
185
186
  ### Utility functions
186
187
 
@@ -205,6 +205,7 @@ LLM は外枠パイプの省略、区切り行の欠落、列数の不一致な
205
205
  - 見える行に挟まれた内部空行だけを書き換える
206
206
  - 先頭・末尾の空行のまとまりはそのまま残す
207
207
  - table 領域には適用しない
208
+ - リスト項目の内容直後の空行はそのまま残す
208
209
  - setext 見出しの下線直前の空行はそのまま残す
209
210
  - 参照リンク定義直前の空行はそのまま残す
210
211
  - プレビュー文字列と `blocks_to_plain_text` では補助用のマーカーを除去し、元の空行構造に戻す
@@ -207,6 +207,7 @@ This workaround is intentionally narrow:
207
207
  - Only blank lines between visible lines are rewritten
208
208
  - Leading and trailing blank runs are left untouched
209
209
  - Table segments are not modified by this option
210
+ - Blank runs immediately after list-item content are left untouched
210
211
  - Blank runs immediately before setext-heading underlines are left untouched
211
212
  - Blank runs immediately before reference-link definitions are left untouched
212
213
  - Preview text and `blocks_to_plain_text` remove those placeholder markers again, preserving the original blank lines in plain-text output
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "slack-markdown-parser"
7
- version = "2.3.0"
7
+ version = "2.3.2"
8
8
  description = "Convert LLM Markdown into Slack Block Kit markdown/table messages"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -33,7 +33,6 @@ dev = [
33
33
  "pytest>=8.0.0",
34
34
  "pytest-cov>=5.0.0",
35
35
  "ruff>=0.6.0",
36
- "twine>=5.1.0",
37
36
  ]
38
37
 
39
38
  [project.urls]
@@ -57,4 +56,7 @@ target-version = "py310"
57
56
 
58
57
  [tool.ruff.lint]
59
58
  select = ["E", "F", "I", "UP", "B"]
60
- ignore = ["E501", "UP006", "UP035", "UP045"]
59
+ ignore = ["E501"] # line length is enforced by black
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
@@ -1,6 +1,6 @@
1
1
  """slack-markdown-parser public package API."""
2
2
 
3
- __version__ = "2.3.0"
3
+ __version__ = "2.3.2"
4
4
  __license__ = "MIT"
5
5
 
6
6
  from .converter import (
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import html
10
10
  import re
11
- from typing import Any, Dict, List, Optional
11
+ from typing import Any
12
12
 
13
13
  ZWSP = "\u200b"
14
14
  NBSP = "\u00a0"
@@ -42,6 +42,12 @@ PROTECTED_UNDERSCORE_SPAN_PATTERN = re.compile(
42
42
  )
43
43
  REFERENCE_DEFINITION_PATTERN = re.compile(r"^[ \t]{0,3}\[[^\]\n]+\]:")
44
44
  SETEXT_HEADING_UNDERLINE_PATTERN = re.compile(r"^[ \t]{0,3}(?:=+|-+)\s*$")
45
+ THEMATIC_BREAK_PATTERN = re.compile(
46
+ r"^[ \t]{0,3}(?P<char>[-_*])(?:[ \t]*\1){2,}[ \t]*$"
47
+ )
48
+ LIST_ITEM_PATTERN = re.compile(
49
+ r"^(?P<indent>[ \t]*)(?P<marker>\d+[.)]|[-+*])(?P<spacing>[ \t]+|$)"
50
+ )
45
51
  DOUBLE_UNDERSCORE_EMPHASIS_PATTERN = re.compile(
46
52
  r"(?<![\\0-9A-Za-z_])__(?=\S)(.+?\S)__(?![0-9A-Za-z_])"
47
53
  )
@@ -118,7 +124,7 @@ def _nested_code_space_strategy(
118
124
  source: str,
119
125
  start: int,
120
126
  end: int,
121
- boundary_chars: Optional[set[str]] = None,
127
+ boundary_chars: set[str] | None = None,
122
128
  ) -> str | None:
123
129
  boundary_chars = boundary_chars or {*VISIBLE_BOUNDARY_CHARS, ZWSP}
124
130
  neighbors = []
@@ -153,8 +159,20 @@ def _normalize_markdown_block_plain_text(text: str) -> str:
153
159
  return re.sub(r"<(https?://[^>\s|]+)>", r"\1", text)
154
160
 
155
161
 
162
+ def _build_markdown_block_plain_text(
163
+ text: str, synthetic_space_indices: list[int] | None = None
164
+ ) -> str:
165
+ """Build fallback/plain text for a markdown block before visual-only rewrites."""
166
+ return _normalize_markdown_block_plain_text(
167
+ _strip_synthetic_spaces_from_plain_text(
168
+ strip_zero_width_spaces(text),
169
+ synthetic_space_indices,
170
+ )
171
+ )
172
+
173
+
156
174
  def _strip_synthetic_spaces_from_plain_text(
157
- text: str, synthetic_space_indices: Optional[List[int]] = None
175
+ text: str, synthetic_space_indices: list[int] | None = None
158
176
  ) -> str:
159
177
  if not text or not synthetic_space_indices:
160
178
  return text
@@ -165,13 +183,148 @@ def _strip_synthetic_spaces_from_plain_text(
165
183
  )
166
184
 
167
185
 
168
- def _inject_visual_blank_line_placeholders(text: str) -> tuple[str, List[int]]:
186
+ def _indent_width(text: str) -> int:
187
+ width = 0
188
+ for char in text:
189
+ if char == " ":
190
+ width += 1
191
+ elif char == "\t":
192
+ # Treat tabs conservatively for list-continuation heuristics.
193
+ width += 4
194
+ else:
195
+ break
196
+ return width
197
+
198
+
199
+ def _list_item_content_indent(match: re.Match[str]) -> int:
200
+ spacing = match.group("spacing") or ""
201
+ spacing_width = _indent_width(spacing) if spacing else 1
202
+ return (
203
+ _indent_width(match.group("indent") or "")
204
+ + len(match.group("marker") or "")
205
+ + spacing_width
206
+ )
207
+
208
+
209
+ def _is_ordered_list_marker(marker: str) -> bool:
210
+ return bool(marker) and marker[0].isdigit()
211
+
212
+
213
+ def _is_thematic_break_line(line: str) -> bool:
214
+ return bool(THEMATIC_BREAK_PATTERN.match(line))
215
+
216
+
217
+ def _ordered_list_marker_starts_at_one(marker: str) -> bool:
218
+ if not _is_ordered_list_marker(marker):
219
+ return False
220
+ return marker[:-1] == "1"
221
+
222
+
223
+ def _starts_root_list_item(lines: list[str], marker_index: int) -> bool:
224
+ line = lines[marker_index]
225
+ if _is_thematic_break_line(line):
226
+ return False
227
+ match = LIST_ITEM_PATTERN.match(line)
228
+ if not match or _indent_width(match.group("indent") or "") > 3:
229
+ return False
230
+
231
+ marker = match.group("marker") or ""
232
+ if marker_index == 0 or not lines[marker_index - 1].strip(" \t\r"):
233
+ return True
234
+
235
+ if not _is_ordered_list_marker(marker):
236
+ return True
237
+
238
+ return _ordered_list_marker_starts_at_one(marker)
239
+
240
+
241
+ def _line_belongs_to_list_context(
242
+ lines: list[str], *, marker_index: int, target_index: int
243
+ ) -> bool:
244
+ marker_match = LIST_ITEM_PATTERN.match(lines[marker_index])
245
+ if not marker_match:
246
+ return False
247
+
248
+ list_indent_stack = [
249
+ (
250
+ _indent_width(marker_match.group("indent") or ""),
251
+ _list_item_content_indent(marker_match),
252
+ )
253
+ ]
254
+
255
+ for idx in range(marker_index + 1, target_index + 1):
256
+ line = lines[idx]
257
+ if not line.strip(" \t\r"):
258
+ return False
259
+
260
+ line_indent = _indent_width(line)
261
+ nested_match = LIST_ITEM_PATTERN.match(line)
262
+ if nested_match and not _is_thematic_break_line(line):
263
+ marker_indent = _indent_width(nested_match.group("indent") or "")
264
+ while list_indent_stack and marker_indent < list_indent_stack[-1][0]:
265
+ list_indent_stack.pop()
266
+
267
+ if not list_indent_stack:
268
+ return False
269
+
270
+ if marker_indent == list_indent_stack[-1][0]:
271
+ list_indent_stack[-1] = (
272
+ marker_indent,
273
+ _list_item_content_indent(nested_match),
274
+ )
275
+ continue
276
+
277
+ if marker_indent >= list_indent_stack[-1][1]:
278
+ list_indent_stack.append(
279
+ (
280
+ marker_indent,
281
+ _list_item_content_indent(nested_match),
282
+ )
283
+ )
284
+ continue
285
+
286
+ return False
287
+
288
+ while len(list_indent_stack) > 1 and line_indent < list_indent_stack[-1][0]:
289
+ list_indent_stack.pop()
290
+
291
+ if line_indent >= list_indent_stack[-1][1]:
292
+ continue
293
+
294
+ return False
295
+
296
+ return True
297
+
298
+
299
+ def _blank_run_follows_list_context(lines: list[str], blank_start: int) -> bool:
300
+ previous_visible_index = blank_start - 1
301
+ if previous_visible_index < 0 or not lines[previous_visible_index].strip(" \t\r"):
302
+ return False
303
+
304
+ block_start = previous_visible_index
305
+ while block_start > 0 and lines[block_start - 1].strip(" \t\r"):
306
+ block_start -= 1
307
+
308
+ for marker_index in range(previous_visible_index, block_start - 1, -1):
309
+ if not _starts_root_list_item(lines, marker_index):
310
+ continue
311
+ if _line_belongs_to_list_context(
312
+ lines, marker_index=marker_index, target_index=previous_visible_index
313
+ ):
314
+ return True
315
+
316
+ return False
317
+
318
+
319
+ def _inject_visual_blank_line_placeholders_in_chunk(
320
+ text: str,
321
+ ) -> tuple[str, list[int]]:
169
322
  """Replace internal blank lines with NBSP-only lines for Slack rendering."""
170
323
  if not text or "\n" not in text:
171
324
  return text, []
172
325
 
173
326
  lines = text.split("\n")
174
- rewritten: List[tuple[str, bool]] = []
327
+ rewritten: list[tuple[str, bool]] = []
175
328
  i = 0
176
329
 
177
330
  while i < len(lines):
@@ -188,6 +341,9 @@ def _inject_visual_blank_line_placeholders(text: str) -> tuple[str, List[int]]:
188
341
  lines[blank_start - 1].strip(" \t\r")
189
342
  )
190
343
  has_visible_line_after = i < len(lines) and bool(lines[i].strip(" \t\r"))
344
+ blank_run_follows_list_context = has_visible_line_before and (
345
+ _blank_run_follows_list_context(lines, blank_start)
346
+ )
191
347
  next_visible_starts_reference_definition = has_visible_line_after and bool(
192
348
  REFERENCE_DEFINITION_PATTERN.match(lines[i])
193
349
  )
@@ -203,6 +359,7 @@ def _inject_visual_blank_line_placeholders(text: str) -> tuple[str, List[int]]:
203
359
  if (
204
360
  has_visible_line_before
205
361
  and has_visible_line_after
362
+ and not blank_run_follows_list_context
206
363
  and not next_visible_starts_reference_definition
207
364
  and not next_visible_starts_setext_heading
208
365
  ):
@@ -210,8 +367,8 @@ def _inject_visual_blank_line_placeholders(text: str) -> tuple[str, List[int]]:
210
367
  else:
211
368
  rewritten.extend((line, False) for line in lines[blank_start:i])
212
369
 
213
- rebuilt_parts: List[str] = []
214
- synthetic_indices: List[int] = []
370
+ rebuilt_parts: list[str] = []
371
+ synthetic_indices: list[int] = []
215
372
  offset = 0
216
373
 
217
374
  for idx, (line, is_synthetic) in enumerate(rewritten):
@@ -226,8 +383,33 @@ def _inject_visual_blank_line_placeholders(text: str) -> tuple[str, List[int]]:
226
383
  return "".join(rebuilt_parts), synthetic_indices
227
384
 
228
385
 
386
+ def _inject_visual_blank_line_placeholders(text: str) -> tuple[str, list[int]]:
387
+ """Replace internal blank lines outside fenced code blocks."""
388
+ if not text or "\n" not in text:
389
+ return text, []
390
+
391
+ rebuilt_parts: list[str] = []
392
+ synthetic_indices: list[int] = []
393
+ offset = 0
394
+
395
+ for is_fenced, chunk in _split_fenced_code_chunks(text):
396
+ if is_fenced:
397
+ rebuilt_parts.append(chunk)
398
+ offset += len(chunk)
399
+ continue
400
+
401
+ rewritten_chunk, chunk_indices = (
402
+ _inject_visual_blank_line_placeholders_in_chunk(chunk)
403
+ )
404
+ rebuilt_parts.append(rewritten_chunk)
405
+ synthetic_indices.extend(offset + idx for idx in chunk_indices)
406
+ offset += len(rewritten_chunk)
407
+
408
+ return "".join(rebuilt_parts), synthetic_indices
409
+
410
+
229
411
  def _strip_synthetic_blank_line_placeholders(
230
- text: str, synthetic_blank_line_indices: Optional[List[int]] = None
412
+ text: str, synthetic_blank_line_indices: list[int] | None = None
231
413
  ) -> str:
232
414
  if not text or not synthetic_blank_line_indices:
233
415
  return text
@@ -238,12 +420,12 @@ def _strip_synthetic_blank_line_placeholders(
238
420
  )
239
421
 
240
422
 
241
- def _remove_synthetic_space_markers(text: str) -> tuple[str, List[int]]:
423
+ def _remove_synthetic_space_markers(text: str) -> tuple[str, list[int]]:
242
424
  if not text or SYNTH_SPACE_MARKER not in text:
243
425
  return text, []
244
426
 
245
- cleaned: List[str] = []
246
- synthetic_indices: List[int] = []
427
+ cleaned: list[str] = []
428
+ synthetic_indices: list[int] = []
247
429
  mark_next_space = False
248
430
 
249
431
  for char in text:
@@ -327,7 +509,7 @@ def normalize_bare_urls_for_slack_markdown(text: str) -> str:
327
509
  return text
328
510
 
329
511
  def wrap_chunk(chunk: str) -> str:
330
- parts: List[str] = []
512
+ parts: list[str] = []
331
513
  cursor = 0
332
514
  length = len(chunk)
333
515
 
@@ -391,7 +573,7 @@ def sanitize_slack_text(text: str) -> str:
391
573
  return SLACK_ANGLE_TOKEN_PATTERN.sub(replace_invalid_token, cleaned)
392
574
 
393
575
 
394
- def _match_fence_open(line: str) -> Optional[tuple[str, int]]:
576
+ def _match_fence_open(line: str) -> tuple[str, int] | None:
395
577
  match = FENCE_OPEN_PATTERN.match(line)
396
578
  if not match:
397
579
  return None
@@ -408,13 +590,13 @@ def _is_fence_close(line: str, fence: tuple[str, int]) -> bool:
408
590
  )
409
591
 
410
592
 
411
- def _split_fenced_code_chunks(text: str) -> List[tuple[bool, str]]:
412
- chunks: List[tuple[bool, str]] = []
593
+ def _split_fenced_code_chunks(text: str) -> list[tuple[bool, str]]:
594
+ chunks: list[tuple[bool, str]] = []
413
595
  if not text:
414
596
  return chunks
415
597
 
416
- current: List[str] = []
417
- active_fence: Optional[tuple[str, int]] = None
598
+ current: list[str] = []
599
+ active_fence: tuple[str, int] | None = None
418
600
 
419
601
  for line in text.splitlines(keepends=True):
420
602
  opening_fence = _match_fence_open(line) if active_fence is None else None
@@ -440,7 +622,7 @@ def _split_fenced_code_chunks(text: str) -> List[tuple[bool, str]]:
440
622
 
441
623
 
442
624
  def _normalize_underscore_emphasis_chunk(text: str) -> str:
443
- protected_spans: List[str] = []
625
+ protected_spans: list[str] = []
444
626
 
445
627
  def protect(match: re.Match[str]) -> str:
446
628
  token = f"\ufff0{len(protected_spans)}\ufff1"
@@ -478,7 +660,7 @@ def add_zero_width_spaces_to_markdown(text: str) -> str:
478
660
  return formatted
479
661
 
480
662
 
481
- def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
663
+ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, list[int]]:
482
664
  """Return formatted markdown text plus synthetic visible-space positions."""
483
665
  if not text:
484
666
  return text, []
@@ -576,12 +758,12 @@ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
576
758
  suffix = STRIP_RIGHT_ZWSP_MARKER if after_char == ZWSP else ""
577
759
  return f"{prefix}{adjusted_text}{suffix}"
578
760
 
579
- def wrap_segment(segment: str) -> tuple[str, List[int]]:
761
+ def wrap_segment(segment: str) -> tuple[str, list[int]]:
580
762
  if not segment:
581
763
  return segment, []
582
764
 
583
765
  placeholder_map: dict[str, dict[str, str]] = {}
584
- protected_parts: List[str] = []
766
+ protected_parts: list[str] = []
585
767
  last_end = 0
586
768
 
587
769
  for idx, match in enumerate(INLINE_CODE_SPAN_PATTERN.finditer(segment)):
@@ -651,8 +833,8 @@ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
651
833
  return _remove_synthetic_space_markers(protected_segment)
652
834
 
653
835
  chunks = _split_fenced_code_chunks(text)
654
- combined_parts: List[str] = []
655
- combined_indices: List[int] = []
836
+ combined_parts: list[str] = []
837
+ combined_indices: list[int] = []
656
838
  offset = 0
657
839
  for is_fenced, chunk in chunks:
658
840
  if is_fenced:
@@ -671,7 +853,7 @@ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
671
853
  add_zero_width_spaces = add_zero_width_spaces_to_markdown
672
854
 
673
855
 
674
- def _split_markdown_table_cells(line: str) -> List[str]:
856
+ def _split_markdown_table_cells(line: str) -> list[str]:
675
857
  """Split markdown table cells while preserving pipes inside <...|...> links."""
676
858
  working = line.strip()
677
859
  if not working:
@@ -682,8 +864,8 @@ def _split_markdown_table_cells(line: str) -> List[str]:
682
864
  if working.endswith("|"):
683
865
  working = working[:-1]
684
866
 
685
- cells: List[str] = []
686
- current: List[str] = []
867
+ cells: list[str] = []
868
+ current: list[str] = []
687
869
  in_angle = False
688
870
  escaped = False
689
871
  cursor = 0
@@ -733,8 +915,8 @@ def _count_cell_words(cell_text: str) -> int:
733
915
 
734
916
 
735
917
  def _split_heading_prefix_and_first_cell(
736
- heading_prefix: str, reference_cell: Optional[str]
737
- ) -> Optional[tuple[str, str]]:
918
+ heading_prefix: str, reference_cell: str | None
919
+ ) -> tuple[str, str] | None:
738
920
  tokens = [token for token in heading_prefix.strip().split() if token]
739
921
  if len(tokens) < 2:
740
922
  return None
@@ -753,8 +935,8 @@ def _split_heading_prefix_and_first_cell(
753
935
 
754
936
 
755
937
  def _split_heading_and_table_row(
756
- line: str, next_line: Optional[str] = None
757
- ) -> Optional[tuple[str, str]]:
938
+ line: str, next_line: str | None = None
939
+ ) -> tuple[str, str] | None:
758
940
  """Split lines like '# Heading |a|b|' into heading and table row."""
759
941
  if "|" not in line:
760
942
  return None
@@ -812,7 +994,7 @@ def _split_heading_and_table_row(
812
994
  if not explicit_cells:
813
995
  return None
814
996
 
815
- reference_cell: Optional[str] = None
997
+ reference_cell: str | None = None
816
998
  if (
817
999
  next_line
818
1000
  and "|" in next_line
@@ -848,10 +1030,10 @@ def normalize_markdown_tables(markdown_text: str) -> str:
848
1030
  return markdown_text
849
1031
 
850
1032
  lines = markdown_text.splitlines()
851
- normalized: List[str] = []
852
- buffer: List[str] = []
1033
+ normalized: list[str] = []
1034
+ buffer: list[str] = []
853
1035
 
854
- def is_table_block(candidates: List[str]) -> bool:
1036
+ def is_table_block(candidates: list[str]) -> bool:
855
1037
  if len(candidates) < 2:
856
1038
  return False
857
1039
  if any(
@@ -859,7 +1041,7 @@ def normalize_markdown_tables(markdown_text: str) -> str:
859
1041
  ):
860
1042
  return True
861
1043
 
862
- column_counts: List[int] = []
1044
+ column_counts: list[int] = []
863
1045
  for line in candidates:
864
1046
  working = line.strip()
865
1047
  if "|" not in working:
@@ -916,7 +1098,7 @@ def normalize_markdown_tables(markdown_text: str) -> str:
916
1098
  normalized.extend(buffer)
917
1099
  buffer = []
918
1100
 
919
- active_fence: Optional[tuple[str, int]] = None
1101
+ active_fence: tuple[str, int] | None = None
920
1102
 
921
1103
  for idx, line in enumerate(lines):
922
1104
  opening_fence = _match_fence_open(line) if active_fence is None else None
@@ -964,13 +1146,13 @@ def looks_like_markdown_table(text: str) -> bool:
964
1146
  return table_like_lines >= 2
965
1147
 
966
1148
 
967
- def _create_table_cell(text: str) -> Dict[str, Any]:
1149
+ def _create_table_cell(text: str) -> dict[str, Any]:
968
1150
  """Build Slack rich_text cell from markdown fragment."""
969
1151
  clean_text = strip_zero_width_spaces(text or "")
970
1152
  clean_text = clean_text.replace("\\|", "|")
971
1153
  if not clean_text.strip():
972
1154
  clean_text = "-"
973
- elements: List[Dict[str, Any]] = []
1155
+ elements: list[dict[str, Any]] = []
974
1156
  last_index = 0
975
1157
 
976
1158
  for match in TABLE_TOKEN_PATTERN.finditer(clean_text):
@@ -979,7 +1161,7 @@ def _create_table_cell(text: str) -> Dict[str, Any]:
979
1161
  if prefix:
980
1162
  elements.append({"type": "text", "text": prefix})
981
1163
 
982
- element: Dict[str, Any]
1164
+ element: dict[str, Any]
983
1165
  markdown_label = match.group("markdown_label")
984
1166
  markdown_url = match.group("markdown_url")
985
1167
  angle_url = match.group("angle_url")
@@ -995,7 +1177,7 @@ def _create_table_cell(text: str) -> Dict[str, Any]:
995
1177
  "text": angle_label or angle_url,
996
1178
  }
997
1179
  else:
998
- style: Dict[str, bool] = {}
1180
+ style: dict[str, bool] = {}
999
1181
  content = token
1000
1182
 
1001
1183
  if content.startswith("`") and content.endswith("`"):
@@ -1031,13 +1213,13 @@ def _create_table_cell(text: str) -> Dict[str, Any]:
1031
1213
  }
1032
1214
 
1033
1215
 
1034
- def extract_plain_text_from_table_cell(cell: Dict[str, Any]) -> str:
1216
+ def extract_plain_text_from_table_cell(cell: dict[str, Any]) -> str:
1035
1217
  """Extract plain text from a Slack table cell object."""
1036
1218
  if not isinstance(cell, dict):
1037
1219
  return ""
1038
1220
 
1039
1221
  if cell.get("type") == "rich_text":
1040
- texts: List[str] = []
1222
+ texts: list[str] = []
1041
1223
  for element in cell.get("elements", []):
1042
1224
  if not isinstance(element, dict):
1043
1225
  continue
@@ -1055,13 +1237,13 @@ def extract_plain_text_from_table_cell(cell: Dict[str, Any]) -> str:
1055
1237
  return str(cell.get("text", ""))
1056
1238
 
1057
1239
 
1058
- def markdown_table_to_slack_table(table_markdown: str) -> Optional[Dict[str, Any]]:
1240
+ def markdown_table_to_slack_table(table_markdown: str) -> dict[str, Any] | None:
1059
1241
  """Convert markdown table text to Slack table block."""
1060
1242
  lines = [
1061
1243
  line.rstrip() for line in table_markdown.strip().splitlines() if line.strip()
1062
1244
  ]
1063
- rows: List[List[Dict[str, Any]]] = []
1064
- expected_columns: Optional[int] = None
1245
+ rows: list[list[dict[str, Any]]] = []
1246
+ expected_columns: int | None = None
1065
1247
 
1066
1248
  for line in lines:
1067
1249
  if TABLE_SEPARATOR_PATTERN.match(line):
@@ -1092,15 +1274,15 @@ def markdown_table_to_slack_table(table_markdown: str) -> Optional[Dict[str, Any
1092
1274
  markdown_table_to_table_block = markdown_table_to_slack_table
1093
1275
 
1094
1276
 
1095
- def split_markdown_into_segments(markdown_text: str) -> List[Dict[str, str]]:
1277
+ def split_markdown_into_segments(markdown_text: str) -> list[dict[str, str]]:
1096
1278
  """Split markdown into alternating text/table segments."""
1097
- segments: List[Dict[str, str]] = []
1279
+ segments: list[dict[str, str]] = []
1098
1280
  if not markdown_text:
1099
1281
  return segments
1100
1282
 
1101
1283
  lines = markdown_text.splitlines()
1102
- current: List[str] = []
1103
- current_is_table: Optional[bool] = None
1284
+ current: list[str] = []
1285
+ current_is_table: bool | None = None
1104
1286
 
1105
1287
  def flush() -> None:
1106
1288
  nonlocal current, current_is_table
@@ -1114,7 +1296,7 @@ def split_markdown_into_segments(markdown_text: str) -> List[Dict[str, str]]:
1114
1296
  current = []
1115
1297
  current_is_table = None
1116
1298
 
1117
- active_fence: Optional[tuple[str, int]] = None
1299
+ active_fence: tuple[str, int] | None = None
1118
1300
 
1119
1301
  for line in lines:
1120
1302
  stripped = line.strip()
@@ -1147,7 +1329,7 @@ def split_markdown_into_segments(markdown_text: str) -> List[Dict[str, str]]:
1147
1329
 
1148
1330
  def convert_markdown_to_slack_blocks(
1149
1331
  markdown_text: str, *, preserve_visual_blank_lines: bool = False
1150
- ) -> List[Dict[str, Any]]:
1332
+ ) -> list[dict[str, Any]]:
1151
1333
  """Convert markdown text into Slack markdown/table blocks."""
1152
1334
  if not markdown_text:
1153
1335
  return []
@@ -1157,7 +1339,7 @@ def convert_markdown_to_slack_blocks(
1157
1339
  markdown_text = normalize_underscore_emphasis(markdown_text)
1158
1340
  markdown_text = normalize_bare_urls_for_slack_markdown(markdown_text)
1159
1341
  markdown_text = normalize_markdown_tables(markdown_text)
1160
- blocks: List[Dict[str, Any]] = []
1342
+ blocks: list[dict[str, Any]] = []
1161
1343
 
1162
1344
  for segment in split_markdown_into_segments(markdown_text):
1163
1345
  content = segment.get("content", "")
@@ -1171,13 +1353,15 @@ def convert_markdown_to_slack_blocks(
1171
1353
  continue
1172
1354
 
1173
1355
  formatted, synthetic_indices = _format_markdown_with_spacing_metadata(content)
1174
- synthetic_blank_line_indices: List[int] = []
1356
+ plain_text = _build_markdown_block_plain_text(formatted, synthetic_indices)
1357
+ synthetic_blank_line_indices: list[int] = []
1175
1358
  if preserve_visual_blank_lines:
1176
1359
  formatted, synthetic_blank_line_indices = (
1177
1360
  _inject_visual_blank_line_placeholders(formatted)
1178
1361
  )
1179
1362
  if formatted.strip():
1180
1363
  block = _AnnotatedSlackBlock({"type": "markdown", "text": formatted})
1364
+ block._plain_text = plain_text
1181
1365
  block._synthetic_space_indices = synthetic_indices
1182
1366
  block._synthetic_blank_line_indices = synthetic_blank_line_indices
1183
1367
  blocks.append(block)
@@ -1189,10 +1373,10 @@ def convert_markdown_to_slack_blocks(
1189
1373
  convert_markdown_text_to_blocks = convert_markdown_to_slack_blocks
1190
1374
 
1191
1375
 
1192
- def split_blocks_by_table(blocks: List[Dict[str, Any]]) -> List[List[Dict[str, Any]]]:
1376
+ def split_blocks_by_table(blocks: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
1193
1377
  """Split blocks into multiple messages to satisfy one-table-per-message constraint."""
1194
- messages: List[List[Dict[str, Any]]] = []
1195
- current_message: List[Dict[str, Any]] = []
1378
+ messages: list[list[dict[str, Any]]] = []
1379
+ current_message: list[dict[str, Any]] = []
1196
1380
 
1197
1381
  for block in blocks or []:
1198
1382
  if isinstance(block, dict) and block.get("type") == "table":
@@ -1213,7 +1397,7 @@ def convert_markdown_to_slack_messages(
1213
1397
  markdown_text: str,
1214
1398
  *,
1215
1399
  preserve_visual_blank_lines: bool = False,
1216
- ) -> List[List[Dict[str, Any]]]:
1400
+ ) -> list[list[dict[str, Any]]]:
1217
1401
  """Convert markdown text into a list of Slack message block groups."""
1218
1402
  blocks = convert_markdown_to_slack_blocks(
1219
1403
  markdown_text, preserve_visual_blank_lines=preserve_visual_blank_lines
@@ -1227,9 +1411,9 @@ def convert_markdown_to_slack_payloads(
1227
1411
  markdown_text: str,
1228
1412
  *,
1229
1413
  preserve_visual_blank_lines: bool = False,
1230
- ) -> List[Dict[str, Any]]:
1414
+ ) -> list[dict[str, Any]]:
1231
1415
  """Convert markdown text into Slack-ready payload dicts with fallback text."""
1232
- payloads: List[Dict[str, Any]] = []
1416
+ payloads: list[dict[str, Any]] = []
1233
1417
  for blocks in convert_markdown_to_slack_messages(
1234
1418
  markdown_text, preserve_visual_blank_lines=preserve_visual_blank_lines
1235
1419
  ):
@@ -1238,31 +1422,33 @@ def convert_markdown_to_slack_payloads(
1238
1422
  return payloads
1239
1423
 
1240
1424
 
1241
- def blocks_to_plain_text(blocks: List[Dict[str, Any]]) -> str:
1425
+ def blocks_to_plain_text(blocks: list[dict[str, Any]]) -> str:
1242
1426
  """Build plain text representation from Slack blocks."""
1243
- parts: List[str] = []
1427
+ parts: list[str] = []
1244
1428
 
1245
1429
  for block in blocks or []:
1246
1430
  block_type = block.get("type") if isinstance(block, dict) else None
1247
1431
 
1248
1432
  if block_type == "markdown":
1249
- text = block.get("text", "")
1250
- if text:
1251
- parts.append(
1252
- _normalize_markdown_block_plain_text(
1433
+ text = getattr(block, "_plain_text", None) or ""
1434
+ if not text:
1435
+ raw_text = block.get("text", "")
1436
+ if raw_text:
1437
+ text = _normalize_markdown_block_plain_text(
1253
1438
  _strip_synthetic_blank_line_placeholders(
1254
1439
  _strip_synthetic_spaces_from_plain_text(
1255
- strip_zero_width_spaces(text),
1440
+ strip_zero_width_spaces(raw_text),
1256
1441
  getattr(block, "_synthetic_space_indices", None),
1257
1442
  ),
1258
1443
  getattr(block, "_synthetic_blank_line_indices", None),
1259
- ),
1444
+ )
1260
1445
  )
1261
- )
1446
+ if text:
1447
+ parts.append(text)
1262
1448
  elif block_type == "table":
1263
1449
  rows = block.get("rows") or []
1264
1450
  for row in rows:
1265
- cell_texts: List[str] = []
1451
+ cell_texts: list[str] = []
1266
1452
  if not isinstance(row, list):
1267
1453
  continue
1268
1454
  for cell in row:
@@ -1279,28 +1465,30 @@ def blocks_to_plain_text(blocks: List[Dict[str, Any]]) -> str:
1279
1465
  return "\n".join([p for p in parts if p]).strip()
1280
1466
 
1281
1467
 
1282
- def build_fallback_text_from_blocks(blocks: List[Dict[str, Any]]) -> str:
1468
+ def build_fallback_text_from_blocks(blocks: list[dict[str, Any]]) -> str:
1283
1469
  """Build Slack fallback text from block structure."""
1284
- plain_parts: List[str] = []
1470
+ plain_parts: list[str] = []
1285
1471
 
1286
1472
  for block in blocks or []:
1287
1473
  if not isinstance(block, dict):
1288
1474
  continue
1289
1475
 
1290
1476
  if block.get("type") == "markdown":
1291
- text = _normalize_markdown_block_plain_text(
1292
- _strip_synthetic_blank_line_placeholders(
1293
- _strip_synthetic_spaces_from_plain_text(
1294
- strip_zero_width_spaces(block.get("text", "")),
1295
- getattr(block, "_synthetic_space_indices", None),
1477
+ text = getattr(block, "_plain_text", None) or ""
1478
+ if not text:
1479
+ text = _normalize_markdown_block_plain_text(
1480
+ _strip_synthetic_blank_line_placeholders(
1481
+ _strip_synthetic_spaces_from_plain_text(
1482
+ strip_zero_width_spaces(block.get("text", "")),
1483
+ getattr(block, "_synthetic_space_indices", None),
1484
+ ),
1485
+ getattr(block, "_synthetic_blank_line_indices", None),
1296
1486
  ),
1297
- getattr(block, "_synthetic_blank_line_indices", None),
1298
- ),
1299
- )
1487
+ )
1300
1488
  if text.strip():
1301
1489
  plain_parts.append(text)
1302
1490
  elif block.get("type") == "table":
1303
- table_lines: List[str] = []
1491
+ table_lines: list[str] = []
1304
1492
  for row in block.get("rows", []):
1305
1493
  if not isinstance(row, list):
1306
1494
  continue
@@ -1314,9 +1502,9 @@ def build_fallback_text_from_blocks(blocks: List[Dict[str, Any]]) -> str:
1314
1502
 
1315
1503
 
1316
1504
  # Backward-compatible helper retained for existing imports.
1317
- def parse_markdown_table(table_text: str) -> List[List[str]]:
1505
+ def parse_markdown_table(table_text: str) -> list[list[str]]:
1318
1506
  """Parse markdown table into row/cell text matrix."""
1319
- rows: List[List[str]] = []
1507
+ rows: list[list[str]] = []
1320
1508
  for line in [line for line in table_text.strip().splitlines() if line.strip()]:
1321
1509
  if TABLE_SEPARATOR_PATTERN.match(line.strip()):
1322
1510
  continue
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-markdown-parser
3
- Version: 2.3.0
3
+ Version: 2.3.2
4
4
  Summary: Convert LLM Markdown into Slack Block Kit markdown/table messages
5
5
  Author: darkgaldragon
6
6
  License-Expression: MIT
@@ -26,7 +26,6 @@ Requires-Dist: pip-audit>=2.7.0; extra == "dev"
26
26
  Requires-Dist: pytest>=8.0.0; extra == "dev"
27
27
  Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
28
28
  Requires-Dist: ruff>=0.6.0; extra == "dev"
29
- Requires-Dist: twine>=5.1.0; extra == "dev"
30
29
  Dynamic: license-file
31
30
 
32
31
  # slack-markdown-parser
@@ -210,8 +209,9 @@ Markdown segments with lines that contain only a non-breaking space. Those
210
209
  placeholder lines are removed again when generating preview plain text, so Slack
211
210
  notifications and logs stay close to the original Markdown source.
212
211
  The current implementation deliberately skips blank runs that sit immediately
213
- before setext-heading underlines or reference-link definitions, because those
214
- boundaries can change Markdown meaning in newer Slack Markdown rendering.
212
+ after list-item content, before setext-heading underlines, or before
213
+ reference-link definitions, because those boundaries can change Markdown meaning in newer
214
+ Slack Markdown rendering or keep list formatting open in some clients.
215
215
 
216
216
  ### Utility functions
217
217
 
@@ -6,4 +6,3 @@ pip-audit>=2.7.0
6
6
  pytest>=8.0.0
7
7
  pytest-cov>=5.0.0
8
8
  ruff>=0.6.0
9
- twine>=5.1.0