slack-markdown-parser 2.3.1__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.1 → slack_markdown_parser-2.3.2}/CHANGELOG.md +7 -0
  2. {slack_markdown_parser-2.3.1/slack_markdown_parser.egg-info → slack_markdown_parser-2.3.2}/PKG-INFO +4 -4
  3. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/README-ja.md +1 -1
  4. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/README.md +3 -2
  5. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/docs/spec-ja.md +1 -0
  6. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/docs/spec.md +1 -0
  7. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/pyproject.toml +5 -3
  8. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/slack_markdown_parser/__init__.py +1 -1
  9. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/slack_markdown_parser/converter.py +214 -71
  10. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2/slack_markdown_parser.egg-info}/PKG-INFO +4 -4
  11. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/slack_markdown_parser.egg-info/requires.txt +0 -1
  12. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/LICENSE +0 -0
  13. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/MANIFEST.in +0 -0
  14. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/setup.cfg +0 -0
  15. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/slack_markdown_parser/py.typed +0 -0
  16. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/slack_markdown_parser.egg-info/SOURCES.txt +0 -0
  17. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/slack_markdown_parser.egg-info/dependency_links.txt +0 -0
  18. {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/slack_markdown_parser.egg-info/top_level.txt +0 -0
@@ -6,6 +6,13 @@ 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
+
9
16
  ## [2.3.1] - 2026-04-10
10
17
 
11
18
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-markdown-parser
3
- Version: 2.3.1
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.1"
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.1"
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 = []
@@ -154,7 +160,7 @@ def _normalize_markdown_block_plain_text(text: str) -> str:
154
160
 
155
161
 
156
162
  def _build_markdown_block_plain_text(
157
- text: str, synthetic_space_indices: Optional[List[int]] = None
163
+ text: str, synthetic_space_indices: list[int] | None = None
158
164
  ) -> str:
159
165
  """Build fallback/plain text for a markdown block before visual-only rewrites."""
160
166
  return _normalize_markdown_block_plain_text(
@@ -166,7 +172,7 @@ def _build_markdown_block_plain_text(
166
172
 
167
173
 
168
174
  def _strip_synthetic_spaces_from_plain_text(
169
- text: str, synthetic_space_indices: Optional[List[int]] = None
175
+ text: str, synthetic_space_indices: list[int] | None = None
170
176
  ) -> str:
171
177
  if not text or not synthetic_space_indices:
172
178
  return text
@@ -177,15 +183,148 @@ def _strip_synthetic_spaces_from_plain_text(
177
183
  )
178
184
 
179
185
 
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
+
180
319
  def _inject_visual_blank_line_placeholders_in_chunk(
181
320
  text: str,
182
- ) -> tuple[str, List[int]]:
321
+ ) -> tuple[str, list[int]]:
183
322
  """Replace internal blank lines with NBSP-only lines for Slack rendering."""
184
323
  if not text or "\n" not in text:
185
324
  return text, []
186
325
 
187
326
  lines = text.split("\n")
188
- rewritten: List[tuple[str, bool]] = []
327
+ rewritten: list[tuple[str, bool]] = []
189
328
  i = 0
190
329
 
191
330
  while i < len(lines):
@@ -202,6 +341,9 @@ def _inject_visual_blank_line_placeholders_in_chunk(
202
341
  lines[blank_start - 1].strip(" \t\r")
203
342
  )
204
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
+ )
205
347
  next_visible_starts_reference_definition = has_visible_line_after and bool(
206
348
  REFERENCE_DEFINITION_PATTERN.match(lines[i])
207
349
  )
@@ -217,6 +359,7 @@ def _inject_visual_blank_line_placeholders_in_chunk(
217
359
  if (
218
360
  has_visible_line_before
219
361
  and has_visible_line_after
362
+ and not blank_run_follows_list_context
220
363
  and not next_visible_starts_reference_definition
221
364
  and not next_visible_starts_setext_heading
222
365
  ):
@@ -224,8 +367,8 @@ def _inject_visual_blank_line_placeholders_in_chunk(
224
367
  else:
225
368
  rewritten.extend((line, False) for line in lines[blank_start:i])
226
369
 
227
- rebuilt_parts: List[str] = []
228
- synthetic_indices: List[int] = []
370
+ rebuilt_parts: list[str] = []
371
+ synthetic_indices: list[int] = []
229
372
  offset = 0
230
373
 
231
374
  for idx, (line, is_synthetic) in enumerate(rewritten):
@@ -240,13 +383,13 @@ def _inject_visual_blank_line_placeholders_in_chunk(
240
383
  return "".join(rebuilt_parts), synthetic_indices
241
384
 
242
385
 
243
- def _inject_visual_blank_line_placeholders(text: str) -> tuple[str, List[int]]:
386
+ def _inject_visual_blank_line_placeholders(text: str) -> tuple[str, list[int]]:
244
387
  """Replace internal blank lines outside fenced code blocks."""
245
388
  if not text or "\n" not in text:
246
389
  return text, []
247
390
 
248
- rebuilt_parts: List[str] = []
249
- synthetic_indices: List[int] = []
391
+ rebuilt_parts: list[str] = []
392
+ synthetic_indices: list[int] = []
250
393
  offset = 0
251
394
 
252
395
  for is_fenced, chunk in _split_fenced_code_chunks(text):
@@ -266,7 +409,7 @@ def _inject_visual_blank_line_placeholders(text: str) -> tuple[str, List[int]]:
266
409
 
267
410
 
268
411
  def _strip_synthetic_blank_line_placeholders(
269
- text: str, synthetic_blank_line_indices: Optional[List[int]] = None
412
+ text: str, synthetic_blank_line_indices: list[int] | None = None
270
413
  ) -> str:
271
414
  if not text or not synthetic_blank_line_indices:
272
415
  return text
@@ -277,12 +420,12 @@ def _strip_synthetic_blank_line_placeholders(
277
420
  )
278
421
 
279
422
 
280
- def _remove_synthetic_space_markers(text: str) -> tuple[str, List[int]]:
423
+ def _remove_synthetic_space_markers(text: str) -> tuple[str, list[int]]:
281
424
  if not text or SYNTH_SPACE_MARKER not in text:
282
425
  return text, []
283
426
 
284
- cleaned: List[str] = []
285
- synthetic_indices: List[int] = []
427
+ cleaned: list[str] = []
428
+ synthetic_indices: list[int] = []
286
429
  mark_next_space = False
287
430
 
288
431
  for char in text:
@@ -366,7 +509,7 @@ def normalize_bare_urls_for_slack_markdown(text: str) -> str:
366
509
  return text
367
510
 
368
511
  def wrap_chunk(chunk: str) -> str:
369
- parts: List[str] = []
512
+ parts: list[str] = []
370
513
  cursor = 0
371
514
  length = len(chunk)
372
515
 
@@ -430,7 +573,7 @@ def sanitize_slack_text(text: str) -> str:
430
573
  return SLACK_ANGLE_TOKEN_PATTERN.sub(replace_invalid_token, cleaned)
431
574
 
432
575
 
433
- def _match_fence_open(line: str) -> Optional[tuple[str, int]]:
576
+ def _match_fence_open(line: str) -> tuple[str, int] | None:
434
577
  match = FENCE_OPEN_PATTERN.match(line)
435
578
  if not match:
436
579
  return None
@@ -447,13 +590,13 @@ def _is_fence_close(line: str, fence: tuple[str, int]) -> bool:
447
590
  )
448
591
 
449
592
 
450
- def _split_fenced_code_chunks(text: str) -> List[tuple[bool, str]]:
451
- chunks: List[tuple[bool, str]] = []
593
+ def _split_fenced_code_chunks(text: str) -> list[tuple[bool, str]]:
594
+ chunks: list[tuple[bool, str]] = []
452
595
  if not text:
453
596
  return chunks
454
597
 
455
- current: List[str] = []
456
- active_fence: Optional[tuple[str, int]] = None
598
+ current: list[str] = []
599
+ active_fence: tuple[str, int] | None = None
457
600
 
458
601
  for line in text.splitlines(keepends=True):
459
602
  opening_fence = _match_fence_open(line) if active_fence is None else None
@@ -479,7 +622,7 @@ def _split_fenced_code_chunks(text: str) -> List[tuple[bool, str]]:
479
622
 
480
623
 
481
624
  def _normalize_underscore_emphasis_chunk(text: str) -> str:
482
- protected_spans: List[str] = []
625
+ protected_spans: list[str] = []
483
626
 
484
627
  def protect(match: re.Match[str]) -> str:
485
628
  token = f"\ufff0{len(protected_spans)}\ufff1"
@@ -517,7 +660,7 @@ def add_zero_width_spaces_to_markdown(text: str) -> str:
517
660
  return formatted
518
661
 
519
662
 
520
- 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]]:
521
664
  """Return formatted markdown text plus synthetic visible-space positions."""
522
665
  if not text:
523
666
  return text, []
@@ -615,12 +758,12 @@ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
615
758
  suffix = STRIP_RIGHT_ZWSP_MARKER if after_char == ZWSP else ""
616
759
  return f"{prefix}{adjusted_text}{suffix}"
617
760
 
618
- def wrap_segment(segment: str) -> tuple[str, List[int]]:
761
+ def wrap_segment(segment: str) -> tuple[str, list[int]]:
619
762
  if not segment:
620
763
  return segment, []
621
764
 
622
765
  placeholder_map: dict[str, dict[str, str]] = {}
623
- protected_parts: List[str] = []
766
+ protected_parts: list[str] = []
624
767
  last_end = 0
625
768
 
626
769
  for idx, match in enumerate(INLINE_CODE_SPAN_PATTERN.finditer(segment)):
@@ -690,8 +833,8 @@ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
690
833
  return _remove_synthetic_space_markers(protected_segment)
691
834
 
692
835
  chunks = _split_fenced_code_chunks(text)
693
- combined_parts: List[str] = []
694
- combined_indices: List[int] = []
836
+ combined_parts: list[str] = []
837
+ combined_indices: list[int] = []
695
838
  offset = 0
696
839
  for is_fenced, chunk in chunks:
697
840
  if is_fenced:
@@ -710,7 +853,7 @@ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
710
853
  add_zero_width_spaces = add_zero_width_spaces_to_markdown
711
854
 
712
855
 
713
- def _split_markdown_table_cells(line: str) -> List[str]:
856
+ def _split_markdown_table_cells(line: str) -> list[str]:
714
857
  """Split markdown table cells while preserving pipes inside <...|...> links."""
715
858
  working = line.strip()
716
859
  if not working:
@@ -721,8 +864,8 @@ def _split_markdown_table_cells(line: str) -> List[str]:
721
864
  if working.endswith("|"):
722
865
  working = working[:-1]
723
866
 
724
- cells: List[str] = []
725
- current: List[str] = []
867
+ cells: list[str] = []
868
+ current: list[str] = []
726
869
  in_angle = False
727
870
  escaped = False
728
871
  cursor = 0
@@ -772,8 +915,8 @@ def _count_cell_words(cell_text: str) -> int:
772
915
 
773
916
 
774
917
  def _split_heading_prefix_and_first_cell(
775
- heading_prefix: str, reference_cell: Optional[str]
776
- ) -> Optional[tuple[str, str]]:
918
+ heading_prefix: str, reference_cell: str | None
919
+ ) -> tuple[str, str] | None:
777
920
  tokens = [token for token in heading_prefix.strip().split() if token]
778
921
  if len(tokens) < 2:
779
922
  return None
@@ -792,8 +935,8 @@ def _split_heading_prefix_and_first_cell(
792
935
 
793
936
 
794
937
  def _split_heading_and_table_row(
795
- line: str, next_line: Optional[str] = None
796
- ) -> Optional[tuple[str, str]]:
938
+ line: str, next_line: str | None = None
939
+ ) -> tuple[str, str] | None:
797
940
  """Split lines like '# Heading |a|b|' into heading and table row."""
798
941
  if "|" not in line:
799
942
  return None
@@ -851,7 +994,7 @@ def _split_heading_and_table_row(
851
994
  if not explicit_cells:
852
995
  return None
853
996
 
854
- reference_cell: Optional[str] = None
997
+ reference_cell: str | None = None
855
998
  if (
856
999
  next_line
857
1000
  and "|" in next_line
@@ -887,10 +1030,10 @@ def normalize_markdown_tables(markdown_text: str) -> str:
887
1030
  return markdown_text
888
1031
 
889
1032
  lines = markdown_text.splitlines()
890
- normalized: List[str] = []
891
- buffer: List[str] = []
1033
+ normalized: list[str] = []
1034
+ buffer: list[str] = []
892
1035
 
893
- def is_table_block(candidates: List[str]) -> bool:
1036
+ def is_table_block(candidates: list[str]) -> bool:
894
1037
  if len(candidates) < 2:
895
1038
  return False
896
1039
  if any(
@@ -898,7 +1041,7 @@ def normalize_markdown_tables(markdown_text: str) -> str:
898
1041
  ):
899
1042
  return True
900
1043
 
901
- column_counts: List[int] = []
1044
+ column_counts: list[int] = []
902
1045
  for line in candidates:
903
1046
  working = line.strip()
904
1047
  if "|" not in working:
@@ -955,7 +1098,7 @@ def normalize_markdown_tables(markdown_text: str) -> str:
955
1098
  normalized.extend(buffer)
956
1099
  buffer = []
957
1100
 
958
- active_fence: Optional[tuple[str, int]] = None
1101
+ active_fence: tuple[str, int] | None = None
959
1102
 
960
1103
  for idx, line in enumerate(lines):
961
1104
  opening_fence = _match_fence_open(line) if active_fence is None else None
@@ -1003,13 +1146,13 @@ def looks_like_markdown_table(text: str) -> bool:
1003
1146
  return table_like_lines >= 2
1004
1147
 
1005
1148
 
1006
- def _create_table_cell(text: str) -> Dict[str, Any]:
1149
+ def _create_table_cell(text: str) -> dict[str, Any]:
1007
1150
  """Build Slack rich_text cell from markdown fragment."""
1008
1151
  clean_text = strip_zero_width_spaces(text or "")
1009
1152
  clean_text = clean_text.replace("\\|", "|")
1010
1153
  if not clean_text.strip():
1011
1154
  clean_text = "-"
1012
- elements: List[Dict[str, Any]] = []
1155
+ elements: list[dict[str, Any]] = []
1013
1156
  last_index = 0
1014
1157
 
1015
1158
  for match in TABLE_TOKEN_PATTERN.finditer(clean_text):
@@ -1018,7 +1161,7 @@ def _create_table_cell(text: str) -> Dict[str, Any]:
1018
1161
  if prefix:
1019
1162
  elements.append({"type": "text", "text": prefix})
1020
1163
 
1021
- element: Dict[str, Any]
1164
+ element: dict[str, Any]
1022
1165
  markdown_label = match.group("markdown_label")
1023
1166
  markdown_url = match.group("markdown_url")
1024
1167
  angle_url = match.group("angle_url")
@@ -1034,7 +1177,7 @@ def _create_table_cell(text: str) -> Dict[str, Any]:
1034
1177
  "text": angle_label or angle_url,
1035
1178
  }
1036
1179
  else:
1037
- style: Dict[str, bool] = {}
1180
+ style: dict[str, bool] = {}
1038
1181
  content = token
1039
1182
 
1040
1183
  if content.startswith("`") and content.endswith("`"):
@@ -1070,13 +1213,13 @@ def _create_table_cell(text: str) -> Dict[str, Any]:
1070
1213
  }
1071
1214
 
1072
1215
 
1073
- 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:
1074
1217
  """Extract plain text from a Slack table cell object."""
1075
1218
  if not isinstance(cell, dict):
1076
1219
  return ""
1077
1220
 
1078
1221
  if cell.get("type") == "rich_text":
1079
- texts: List[str] = []
1222
+ texts: list[str] = []
1080
1223
  for element in cell.get("elements", []):
1081
1224
  if not isinstance(element, dict):
1082
1225
  continue
@@ -1094,13 +1237,13 @@ def extract_plain_text_from_table_cell(cell: Dict[str, Any]) -> str:
1094
1237
  return str(cell.get("text", ""))
1095
1238
 
1096
1239
 
1097
- 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:
1098
1241
  """Convert markdown table text to Slack table block."""
1099
1242
  lines = [
1100
1243
  line.rstrip() for line in table_markdown.strip().splitlines() if line.strip()
1101
1244
  ]
1102
- rows: List[List[Dict[str, Any]]] = []
1103
- expected_columns: Optional[int] = None
1245
+ rows: list[list[dict[str, Any]]] = []
1246
+ expected_columns: int | None = None
1104
1247
 
1105
1248
  for line in lines:
1106
1249
  if TABLE_SEPARATOR_PATTERN.match(line):
@@ -1131,15 +1274,15 @@ def markdown_table_to_slack_table(table_markdown: str) -> Optional[Dict[str, Any
1131
1274
  markdown_table_to_table_block = markdown_table_to_slack_table
1132
1275
 
1133
1276
 
1134
- 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]]:
1135
1278
  """Split markdown into alternating text/table segments."""
1136
- segments: List[Dict[str, str]] = []
1279
+ segments: list[dict[str, str]] = []
1137
1280
  if not markdown_text:
1138
1281
  return segments
1139
1282
 
1140
1283
  lines = markdown_text.splitlines()
1141
- current: List[str] = []
1142
- current_is_table: Optional[bool] = None
1284
+ current: list[str] = []
1285
+ current_is_table: bool | None = None
1143
1286
 
1144
1287
  def flush() -> None:
1145
1288
  nonlocal current, current_is_table
@@ -1153,7 +1296,7 @@ def split_markdown_into_segments(markdown_text: str) -> List[Dict[str, str]]:
1153
1296
  current = []
1154
1297
  current_is_table = None
1155
1298
 
1156
- active_fence: Optional[tuple[str, int]] = None
1299
+ active_fence: tuple[str, int] | None = None
1157
1300
 
1158
1301
  for line in lines:
1159
1302
  stripped = line.strip()
@@ -1186,7 +1329,7 @@ def split_markdown_into_segments(markdown_text: str) -> List[Dict[str, str]]:
1186
1329
 
1187
1330
  def convert_markdown_to_slack_blocks(
1188
1331
  markdown_text: str, *, preserve_visual_blank_lines: bool = False
1189
- ) -> List[Dict[str, Any]]:
1332
+ ) -> list[dict[str, Any]]:
1190
1333
  """Convert markdown text into Slack markdown/table blocks."""
1191
1334
  if not markdown_text:
1192
1335
  return []
@@ -1196,7 +1339,7 @@ def convert_markdown_to_slack_blocks(
1196
1339
  markdown_text = normalize_underscore_emphasis(markdown_text)
1197
1340
  markdown_text = normalize_bare_urls_for_slack_markdown(markdown_text)
1198
1341
  markdown_text = normalize_markdown_tables(markdown_text)
1199
- blocks: List[Dict[str, Any]] = []
1342
+ blocks: list[dict[str, Any]] = []
1200
1343
 
1201
1344
  for segment in split_markdown_into_segments(markdown_text):
1202
1345
  content = segment.get("content", "")
@@ -1211,7 +1354,7 @@ def convert_markdown_to_slack_blocks(
1211
1354
 
1212
1355
  formatted, synthetic_indices = _format_markdown_with_spacing_metadata(content)
1213
1356
  plain_text = _build_markdown_block_plain_text(formatted, synthetic_indices)
1214
- synthetic_blank_line_indices: List[int] = []
1357
+ synthetic_blank_line_indices: list[int] = []
1215
1358
  if preserve_visual_blank_lines:
1216
1359
  formatted, synthetic_blank_line_indices = (
1217
1360
  _inject_visual_blank_line_placeholders(formatted)
@@ -1230,10 +1373,10 @@ def convert_markdown_to_slack_blocks(
1230
1373
  convert_markdown_text_to_blocks = convert_markdown_to_slack_blocks
1231
1374
 
1232
1375
 
1233
- 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]]]:
1234
1377
  """Split blocks into multiple messages to satisfy one-table-per-message constraint."""
1235
- messages: List[List[Dict[str, Any]]] = []
1236
- current_message: List[Dict[str, Any]] = []
1378
+ messages: list[list[dict[str, Any]]] = []
1379
+ current_message: list[dict[str, Any]] = []
1237
1380
 
1238
1381
  for block in blocks or []:
1239
1382
  if isinstance(block, dict) and block.get("type") == "table":
@@ -1254,7 +1397,7 @@ def convert_markdown_to_slack_messages(
1254
1397
  markdown_text: str,
1255
1398
  *,
1256
1399
  preserve_visual_blank_lines: bool = False,
1257
- ) -> List[List[Dict[str, Any]]]:
1400
+ ) -> list[list[dict[str, Any]]]:
1258
1401
  """Convert markdown text into a list of Slack message block groups."""
1259
1402
  blocks = convert_markdown_to_slack_blocks(
1260
1403
  markdown_text, preserve_visual_blank_lines=preserve_visual_blank_lines
@@ -1268,9 +1411,9 @@ def convert_markdown_to_slack_payloads(
1268
1411
  markdown_text: str,
1269
1412
  *,
1270
1413
  preserve_visual_blank_lines: bool = False,
1271
- ) -> List[Dict[str, Any]]:
1414
+ ) -> list[dict[str, Any]]:
1272
1415
  """Convert markdown text into Slack-ready payload dicts with fallback text."""
1273
- payloads: List[Dict[str, Any]] = []
1416
+ payloads: list[dict[str, Any]] = []
1274
1417
  for blocks in convert_markdown_to_slack_messages(
1275
1418
  markdown_text, preserve_visual_blank_lines=preserve_visual_blank_lines
1276
1419
  ):
@@ -1279,9 +1422,9 @@ def convert_markdown_to_slack_payloads(
1279
1422
  return payloads
1280
1423
 
1281
1424
 
1282
- def blocks_to_plain_text(blocks: List[Dict[str, Any]]) -> str:
1425
+ def blocks_to_plain_text(blocks: list[dict[str, Any]]) -> str:
1283
1426
  """Build plain text representation from Slack blocks."""
1284
- parts: List[str] = []
1427
+ parts: list[str] = []
1285
1428
 
1286
1429
  for block in blocks or []:
1287
1430
  block_type = block.get("type") if isinstance(block, dict) else None
@@ -1305,7 +1448,7 @@ def blocks_to_plain_text(blocks: List[Dict[str, Any]]) -> str:
1305
1448
  elif block_type == "table":
1306
1449
  rows = block.get("rows") or []
1307
1450
  for row in rows:
1308
- cell_texts: List[str] = []
1451
+ cell_texts: list[str] = []
1309
1452
  if not isinstance(row, list):
1310
1453
  continue
1311
1454
  for cell in row:
@@ -1322,9 +1465,9 @@ def blocks_to_plain_text(blocks: List[Dict[str, Any]]) -> str:
1322
1465
  return "\n".join([p for p in parts if p]).strip()
1323
1466
 
1324
1467
 
1325
- 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:
1326
1469
  """Build Slack fallback text from block structure."""
1327
- plain_parts: List[str] = []
1470
+ plain_parts: list[str] = []
1328
1471
 
1329
1472
  for block in blocks or []:
1330
1473
  if not isinstance(block, dict):
@@ -1345,7 +1488,7 @@ def build_fallback_text_from_blocks(blocks: List[Dict[str, Any]]) -> str:
1345
1488
  if text.strip():
1346
1489
  plain_parts.append(text)
1347
1490
  elif block.get("type") == "table":
1348
- table_lines: List[str] = []
1491
+ table_lines: list[str] = []
1349
1492
  for row in block.get("rows", []):
1350
1493
  if not isinstance(row, list):
1351
1494
  continue
@@ -1359,9 +1502,9 @@ def build_fallback_text_from_blocks(blocks: List[Dict[str, Any]]) -> str:
1359
1502
 
1360
1503
 
1361
1504
  # Backward-compatible helper retained for existing imports.
1362
- def parse_markdown_table(table_text: str) -> List[List[str]]:
1505
+ def parse_markdown_table(table_text: str) -> list[list[str]]:
1363
1506
  """Parse markdown table into row/cell text matrix."""
1364
- rows: List[List[str]] = []
1507
+ rows: list[list[str]] = []
1365
1508
  for line in [line for line in table_text.strip().splitlines() if line.strip()]:
1366
1509
  if TABLE_SEPARATOR_PATTERN.match(line.strip()):
1367
1510
  continue
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-markdown-parser
3
- Version: 2.3.1
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