slack-markdown-parser 2.2.2__tar.gz → 2.2.4__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 (30) hide show
  1. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/CHANGELOG.md +25 -0
  2. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/PKG-INFO +1 -1
  3. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/pyproject.toml +1 -1
  4. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/slack_markdown_parser/__init__.py +1 -1
  5. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/slack_markdown_parser/converter.py +131 -107
  6. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/slack_markdown_parser.egg-info/PKG-INFO +1 -1
  7. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/tests/test_converter.py +20 -0
  8. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/CONTRIBUTING.md +0 -0
  9. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/LICENSE +0 -0
  10. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/MANIFEST.in +0 -0
  11. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/README-ja.md +0 -0
  12. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/README.md +0 -0
  13. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/docs/slack-client-manual-checklist.md +0 -0
  14. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/docs/slack-nested-modifier-findings.md +0 -0
  15. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/docs/slack-render-test-workflow.md +0 -0
  16. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/docs/spec-ja.md +0 -0
  17. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/docs/spec.md +0 -0
  18. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/setup.cfg +0 -0
  19. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/slack_markdown_parser/py.typed +0 -0
  20. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/slack_markdown_parser.egg-info/SOURCES.txt +0 -0
  21. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/slack_markdown_parser.egg-info/dependency_links.txt +0 -0
  22. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/slack_markdown_parser.egg-info/requires.txt +0 -0
  23. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/slack_markdown_parser.egg-info/top_level.txt +0 -0
  24. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/tests/fixtures/llm_markdown_p0_corpus.md +0 -0
  25. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/tests/fixtures/slack_cjk_inner_code_matrix.md +0 -0
  26. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/tests/fixtures/slack_nested_modifier_matrix.md +0 -0
  27. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/tests/fixtures/slack_nested_modifier_matrix_parens.md +0 -0
  28. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/tests/fixtures/slack_nested_modifier_matrix_quotes.md +0 -0
  29. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/tests/test_llm_markdown_p0_corpus.py +0 -0
  30. {slack_markdown_parser-2.2.2 → slack_markdown_parser-2.2.4}/tests/test_nested_modifier_matrix.py +0 -0
@@ -6,6 +6,31 @@ The format is based on Keep a Changelog, and the project follows Semantic Versio
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.2.4] - 2026-03-11
10
+
11
+ ### Changed
12
+
13
+ - Published a follow-up release so the packaged artifacts match the merged 2.2.3 source changes exactly.
14
+
15
+ ### Fixed
16
+
17
+ - Preserved multi-backtick inline-code spans when splitting table cells and heading-plus-table lines, so pipes inside code spans no longer break parsing.
18
+ - Extended Slack table rich-text cell conversion to keep multi-backtick code spans intact in rendered table cells.
19
+ - Reduced fallback text generation overhead by removing the redundant second plain-text pass during payload assembly.
20
+ - Simplified nested modifier formatting internals so placeholder handling scales more predictably on long messages.
21
+
22
+ ## [2.2.3] - 2026-03-11
23
+
24
+ ### Changed
25
+
26
+ - Reduced fallback text generation overhead by removing the redundant second plain-text pass during payload assembly.
27
+ - Simplified nested modifier formatting internals so placeholder handling scales more predictably on long messages.
28
+
29
+ ### Fixed
30
+
31
+ - Preserved multi-backtick inline-code spans when splitting table cells and heading-plus-table lines, so pipes inside code spans no longer break parsing.
32
+ - Extended Slack table rich-text cell conversion to keep multi-backtick code spans intact in rendered table cells.
33
+
9
34
  ## [2.2.2] - 2026-03-10
10
35
 
11
36
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-markdown-parser
3
- Version: 2.2.2
3
+ Version: 2.2.4
4
4
  Summary: Convert LLM Markdown into Slack Block Kit markdown/table messages
5
5
  Author: darkgaldragon
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "slack-markdown-parser"
7
- version = "2.2.2"
7
+ version = "2.2.4"
8
8
  description = "Convert LLM Markdown into Slack Block Kit markdown/table messages"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  """slack-markdown-parser public package API."""
2
2
 
3
- __version__ = "2.2.2"
3
+ __version__ = "2.2.4"
4
4
  __license__ = "MIT"
5
5
 
6
6
  from .converter import (
@@ -23,6 +23,14 @@ CONTROL_CHAR_PATTERN = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]")
23
23
  SLACK_ANGLE_TOKEN_PATTERN = re.compile(r"<[^>\n]+>")
24
24
  BARE_URL_PATTERN = re.compile(r"https?://[^\s<]+", re.IGNORECASE)
25
25
  FENCE_OPEN_PATTERN = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})([^\n]*)$")
26
+ MARKDOWN_LINK_PATTERN = re.compile(r"\[[^\]\n]+\]\([^\)\n]+\)")
27
+ INLINE_CODE_SPAN_PATTERN = re.compile(r"(?<!`)`[^`\n]+`(?!`)", flags=re.DOTALL)
28
+ EMPHASIS_PATTERNS = (
29
+ re.compile(r"(?<!\*)\*\*(.+?)\*\*(?!\*)", flags=re.DOTALL),
30
+ re.compile(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", flags=re.DOTALL),
31
+ re.compile(r"~~(.+?)~~", flags=re.DOTALL),
32
+ )
33
+ INLINE_CODE_PLACEHOLDER_PATTERN = re.compile(r"\ufff0code\d+\ufff1")
26
34
  PROTECTED_UNDERSCORE_SPAN_PATTERN = re.compile(
27
35
  r"`[^`\n]+`"
28
36
  r"|\[[^\]\n]+\]\([^\)\n]+\)"
@@ -43,9 +51,14 @@ LOOSE_TABLE_SEPARATOR_PATTERN = re.compile(
43
51
  r"^\s*\|?\s*:?-{3,}\s*(\|\s*:?-{3,}\s*)+\|?\s*$"
44
52
  )
45
53
  TABLE_TOKEN_PATTERN = re.compile(
46
- r"\[([^\]\n]+)\]\((https?://[^\s)]+)\)"
47
- r"|<(https?://[^>\s|]+)(?:\|([^>\n]+))?>"
48
- r"|(`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|(?<!\*)\*[^*]+\*(?!\*))"
54
+ r"\[(?P<markdown_label>[^\]\n]+)\]\((?P<markdown_url>https?://[^\s)]+)\)"
55
+ r"|<(?P<angle_url>https?://[^>\s|]+)(?:\|(?P<angle_label>[^>\n]+))?>"
56
+ r"|(?P<token>"
57
+ r"(?P<code>(?P<code_delimiter>`+)(?P<code_text>[^\n]+?)(?P=code_delimiter))"
58
+ r"|~~[^~]+~~"
59
+ r"|\*\*[^*]+\*\*"
60
+ r"|(?<!\*)\*[^*]+\*(?!\*)"
61
+ r")"
49
62
  )
50
63
  ALLOWED_SLACK_ANGLE_TOKEN_PATTERNS = (
51
64
  re.compile(r"^<https?://[^>\s|]+(?:\|[^>\n]+)?>$"),
@@ -130,10 +143,6 @@ def _needs_inner_code_spacing(char: str, boundary_chars: set[str]) -> bool:
130
143
  return bool(char) and char not in boundary_chars and char.isalnum()
131
144
 
132
145
 
133
- def _normalize_synthetic_visible_spaces_for_plain_output(text: str) -> str:
134
- return text
135
-
136
-
137
146
  def _normalize_markdown_block_plain_text(text: str) -> str:
138
147
  if not text:
139
148
  return text
@@ -178,14 +187,23 @@ def _is_allowed_slack_angle_token(token: str) -> bool:
178
187
  return any(pattern.match(token) for pattern in ALLOWED_SLACK_ANGLE_TOKEN_PATTERNS)
179
188
 
180
189
 
190
+ def _find_inline_code_span_end(text: str, start: int) -> int | None:
191
+ delimiter_end = start
192
+ while delimiter_end < len(text) and text[delimiter_end] == "`":
193
+ delimiter_end += 1
194
+
195
+ delimiter = text[start:delimiter_end]
196
+ closing = text.find(delimiter, delimiter_end)
197
+ if closing == -1:
198
+ return None
199
+ return closing + len(delimiter)
200
+
201
+
181
202
  def normalize_bare_urls_for_slack_markdown(text: str) -> str:
182
203
  """Wrap bare URLs in autolink syntax for stable Slack markdown rendering."""
183
204
  if not text:
184
205
  return text
185
206
 
186
- markdown_link_pattern = re.compile(r"\[[^\]\n]+\]\([^\)\n]+\)")
187
- bare_url_pattern = BARE_URL_PATTERN
188
-
189
207
  def wrap_chunk(chunk: str) -> str:
190
208
  parts: List[str] = []
191
209
  cursor = 0
@@ -204,24 +222,20 @@ def normalize_bare_urls_for_slack_markdown(text: str) -> str:
204
222
  continue
205
223
 
206
224
  if char == "[":
207
- link_match = markdown_link_pattern.match(chunk, cursor)
225
+ link_match = MARKDOWN_LINK_PATTERN.match(chunk, cursor)
208
226
  if link_match:
209
227
  parts.append(link_match.group(0))
210
228
  cursor = link_match.end()
211
229
  continue
212
230
 
213
231
  if char == "`":
214
- delimiter_end = cursor
215
- while delimiter_end < length and chunk[delimiter_end] == "`":
216
- delimiter_end += 1
217
- delimiter = chunk[cursor:delimiter_end]
218
- closing = chunk.find(delimiter, delimiter_end)
219
- if closing != -1:
220
- parts.append(chunk[cursor : closing + len(delimiter)])
221
- cursor = closing + len(delimiter)
232
+ code_span_end = _find_inline_code_span_end(chunk, cursor)
233
+ if code_span_end is not None:
234
+ parts.append(chunk[cursor:code_span_end])
235
+ cursor = code_span_end
222
236
  continue
223
237
 
224
- url_match = bare_url_pattern.match(chunk, cursor)
238
+ url_match = BARE_URL_PATTERN.match(chunk, cursor)
225
239
  if url_match:
226
240
  parts.append(f"<{url_match.group(0)}>")
227
241
  cursor = url_match.end()
@@ -233,9 +247,6 @@ def normalize_bare_urls_for_slack_markdown(text: str) -> str:
233
247
  return "".join(parts)
234
248
 
235
249
  chunks = _split_fenced_code_chunks(text)
236
- if not chunks:
237
- return wrap_chunk(text)
238
-
239
250
  return "".join(
240
251
  chunk if is_fenced else wrap_chunk(chunk) for is_fenced, chunk in chunks
241
252
  )
@@ -330,9 +341,6 @@ def normalize_underscore_emphasis(text: str) -> str:
330
341
  return text
331
342
 
332
343
  chunks = _split_fenced_code_chunks(text)
333
- if not chunks:
334
- return text
335
-
336
344
  return "".join(
337
345
  chunk if is_fenced else _normalize_underscore_emphasis_chunk(chunk)
338
346
  for is_fenced, chunk in chunks
@@ -354,12 +362,6 @@ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
354
362
  return text, []
355
363
 
356
364
  boundary_chars = {*VISIBLE_BOUNDARY_CHARS, ZWSP, SYNTH_SPACE_MARKER}
357
- inline_code_pattern = re.compile(r"(?<!`)`[^`\n]+`(?!`)", flags=re.DOTALL)
358
- emphasis_patterns = [
359
- re.compile(r"(?<!\*)\*\*(.+?)\*\*(?!\*)", flags=re.DOTALL),
360
- re.compile(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", flags=re.DOTALL),
361
- re.compile(r"~~(.+?)~~", flags=re.DOTALL),
362
- ]
363
365
 
364
366
  def wrap_match(match: re.Match[str], source: str) -> str:
365
367
  start, end = match.start(), match.end()
@@ -384,44 +386,41 @@ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
384
386
  before_char = source[start - 1] if start > 0 else ""
385
387
  after_char = source[end] if end < len(source) else ""
386
388
  strategy = _nested_code_space_strategy(source, start, end, boundary_chars)
387
- resolved_text = match.group(0)
388
- for placeholder, replacement in replacements.items():
389
- if placeholder in resolved_text:
390
- resolved_text = resolved_text.replace(placeholder, replacement["raw"])
389
+ resolved_text = INLINE_CODE_PLACEHOLDER_PATTERN.sub(
390
+ lambda placeholder_match: replacements[placeholder_match.group(0)]["raw"],
391
+ match.group(0),
392
+ )
391
393
  has_ascii_word = bool(re.search(r"[A-Za-z0-9]", resolved_text))
392
394
  adjusted_text = match.group(0)
393
395
 
394
396
  if strategy in {"ja_zh", "ko"}:
395
- for placeholder in replacements:
396
- search_from = 0
397
- while True:
398
- position = adjusted_text.find(placeholder, search_from)
399
- if position == -1:
400
- break
401
- before_inner = adjusted_text[position - 1] if position > 0 else ""
402
- after_position = position + len(placeholder)
403
- after_inner = (
404
- adjusted_text[after_position]
405
- if after_position < len(adjusted_text)
406
- else ""
407
- )
408
- prefix = (
409
- f"{SYNTH_SPACE_MARKER} "
410
- if _needs_inner_code_spacing(before_inner, boundary_chars)
411
- else ""
412
- )
413
- suffix = (
414
- f"{SYNTH_SPACE_MARKER} "
415
- if _needs_inner_code_spacing(after_inner, boundary_chars)
416
- else ""
417
- )
418
- replacement_text = f"{prefix}{placeholder}{suffix}"
419
- adjusted_text = (
420
- adjusted_text[:position]
421
- + replacement_text
422
- + adjusted_text[position + len(placeholder) :]
423
- )
424
- search_from = position + len(replacement_text)
397
+
398
+ def add_inner_spacing(placeholder_match: re.Match[str]) -> str:
399
+ before_inner = (
400
+ adjusted_text[placeholder_match.start() - 1]
401
+ if placeholder_match.start() > 0
402
+ else ""
403
+ )
404
+ after_inner = (
405
+ adjusted_text[placeholder_match.end()]
406
+ if placeholder_match.end() < len(adjusted_text)
407
+ else ""
408
+ )
409
+ prefix = (
410
+ f"{SYNTH_SPACE_MARKER} "
411
+ if _needs_inner_code_spacing(before_inner, boundary_chars)
412
+ else ""
413
+ )
414
+ suffix = (
415
+ f"{SYNTH_SPACE_MARKER} "
416
+ if _needs_inner_code_spacing(after_inner, boundary_chars)
417
+ else ""
418
+ )
419
+ return f"{prefix}{placeholder_match.group(0)}{suffix}"
420
+
421
+ adjusted_text = INLINE_CODE_PLACEHOLDER_PATTERN.sub(
422
+ add_inner_spacing, adjusted_text
423
+ )
425
424
 
426
425
  if strategy == "ja_zh":
427
426
  prefix = (
@@ -459,7 +458,7 @@ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
459
458
  protected_parts: List[str] = []
460
459
  last_end = 0
461
460
 
462
- for idx, match in enumerate(inline_code_pattern.finditer(segment)):
461
+ for idx, match in enumerate(INLINE_CODE_SPAN_PATTERN.finditer(segment)):
463
462
  placeholder = f"\ufff0code{idx}\ufff1"
464
463
  protected_parts.append(segment[last_end : match.start()])
465
464
  protected_parts.append(placeholder)
@@ -473,32 +472,36 @@ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
473
472
  protected_segment = "".join(protected_parts)
474
473
 
475
474
  placeholders_inside_emphasis: set[str] = set()
476
- for pattern in emphasis_patterns:
475
+ for pattern in EMPHASIS_PATTERNS:
477
476
  for match in pattern.finditer(protected_segment):
478
- matched_text = match.group(0)
479
- for placeholder in placeholder_map:
480
- if placeholder in matched_text:
481
- placeholders_inside_emphasis.add(placeholder)
477
+ placeholders_inside_emphasis.update(
478
+ placeholder.group(0)
479
+ for placeholder in INLINE_CODE_PLACEHOLDER_PATTERN.finditer(
480
+ match.group(0)
481
+ )
482
+ )
482
483
 
483
484
  for placeholder in placeholders_inside_emphasis:
484
485
  placeholder_map[placeholder]["wrapped"] = placeholder_map[placeholder][
485
486
  "raw"
486
487
  ]
487
488
 
488
- for pattern in emphasis_patterns:
489
+ for pattern in EMPHASIS_PATTERNS:
489
490
  protected_segment = pattern.sub(
490
491
  lambda m, s=protected_segment: (
491
492
  wrap_nested_code_emphasis_match(m, s, placeholder_map)
492
- if any(placeholder in m.group(0) for placeholder in placeholder_map)
493
+ if INLINE_CODE_PLACEHOLDER_PATTERN.search(m.group(0))
493
494
  else wrap_match(m, s)
494
495
  ),
495
496
  protected_segment,
496
497
  )
497
498
 
498
- for placeholder, replacement in placeholder_map.items():
499
- protected_segment = protected_segment.replace(
500
- placeholder, replacement["wrapped"]
501
- )
499
+ protected_segment = INLINE_CODE_PLACEHOLDER_PATTERN.sub(
500
+ lambda placeholder_match: placeholder_map[placeholder_match.group(0)][
501
+ "wrapped"
502
+ ],
503
+ protected_segment,
504
+ )
502
505
 
503
506
  protected_segment = re.sub(
504
507
  f"{ZWSP}{re.escape(SYNTH_SPACE_MARKER)} ",
@@ -522,9 +525,6 @@ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
522
525
  return _remove_synthetic_space_markers(protected_segment)
523
526
 
524
527
  chunks = _split_fenced_code_chunks(text)
525
- if not chunks:
526
- return wrap_segment(text)
527
-
528
528
  combined_parts: List[str] = []
529
529
  combined_indices: List[int] = []
530
530
  offset = 0
@@ -545,11 +545,6 @@ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
545
545
  add_zero_width_spaces = add_zero_width_spaces_to_markdown
546
546
 
547
547
 
548
- def _is_strict_markdown_table_line(line: str) -> bool:
549
- stripped = line.strip()
550
- return bool(stripped) and stripped.startswith("|") and stripped.endswith("|")
551
-
552
-
553
548
  def _split_markdown_table_cells(line: str) -> List[str]:
554
549
  """Split markdown table cells while preserving pipes inside <...|...> links."""
555
550
  working = line.strip()
@@ -564,37 +559,43 @@ def _split_markdown_table_cells(line: str) -> List[str]:
564
559
  cells: List[str] = []
565
560
  current: List[str] = []
566
561
  in_angle = False
567
- in_inline_code = False
568
562
  escaped = False
563
+ cursor = 0
569
564
 
570
- for ch in working:
565
+ while cursor < len(working):
566
+ ch = working[cursor]
571
567
  if escaped:
572
568
  current.append(ch)
573
569
  escaped = False
570
+ cursor += 1
574
571
  continue
575
572
 
576
573
  if ch == "\\":
577
574
  current.append(ch)
578
575
  escaped = True
576
+ cursor += 1
579
577
  continue
580
578
 
581
579
  if ch == "`":
582
- in_inline_code = not in_inline_code
583
- current.append(ch)
584
- continue
580
+ code_span_end = _find_inline_code_span_end(working, cursor)
581
+ if code_span_end is not None:
582
+ current.append(working[cursor:code_span_end])
583
+ cursor = code_span_end
584
+ continue
585
585
 
586
- if not in_inline_code:
587
- if ch == "<":
588
- in_angle = True
589
- elif ch == ">" and in_angle:
590
- in_angle = False
586
+ if ch == "<":
587
+ in_angle = True
588
+ elif ch == ">" and in_angle:
589
+ in_angle = False
591
590
 
592
- if ch == "|" and not in_angle and not in_inline_code:
591
+ if ch == "|" and not in_angle:
593
592
  cells.append("".join(current).strip())
594
593
  current = []
594
+ cursor += 1
595
595
  continue
596
596
 
597
597
  current.append(ch)
598
+ cursor += 1
598
599
 
599
600
  cells.append("".join(current).strip())
600
601
  return cells
@@ -632,14 +633,33 @@ def _split_heading_and_table_row(
632
633
  if "|" not in line:
633
634
  return None
634
635
 
635
- in_code = False
636
+ in_angle = False
637
+ escaped = False
636
638
  first_pipe = -1
637
- for i, ch in enumerate(line):
639
+ cursor = 0
640
+ while cursor < len(line):
641
+ ch = line[cursor]
642
+ if escaped:
643
+ escaped = False
644
+ cursor += 1
645
+ continue
646
+ if ch == "\\":
647
+ escaped = True
648
+ cursor += 1
649
+ continue
638
650
  if ch == "`":
639
- in_code = not in_code
640
- elif ch == "|" and not in_code:
641
- first_pipe = i
651
+ code_span_end = _find_inline_code_span_end(line, cursor)
652
+ if code_span_end is not None:
653
+ cursor = code_span_end
654
+ continue
655
+ if ch == "<":
656
+ in_angle = True
657
+ elif ch == ">" and in_angle:
658
+ in_angle = False
659
+ elif ch == "|" and not in_angle:
660
+ first_pipe = cursor
642
661
  break
662
+ cursor += 1
643
663
 
644
664
  if first_pipe < 0:
645
665
  return None
@@ -833,8 +853,13 @@ def _create_table_cell(text: str) -> Dict[str, Any]:
833
853
  if prefix:
834
854
  elements.append({"type": "text", "text": prefix})
835
855
 
836
- markdown_label, markdown_url, angle_url, angle_label, token = match.groups()
837
856
  element: Dict[str, Any]
857
+ markdown_label = match.group("markdown_label")
858
+ markdown_url = match.group("markdown_url")
859
+ angle_url = match.group("angle_url")
860
+ angle_label = match.group("angle_label")
861
+ token = match.group("token") or ""
862
+
838
863
  if markdown_label and markdown_url:
839
864
  element = {"type": "link", "url": markdown_url, "text": markdown_label}
840
865
  elif angle_url:
@@ -845,10 +870,11 @@ def _create_table_cell(text: str) -> Dict[str, Any]:
845
870
  }
846
871
  else:
847
872
  style: Dict[str, bool] = {}
848
- content = token or ""
873
+ content = token
849
874
 
850
875
  if content.startswith("`") and content.endswith("`"):
851
- content = content[1:-1]
876
+ delimiter_len = len(match.group("code_delimiter") or "`")
877
+ content = content[delimiter_len:-delimiter_len]
852
878
  style["code"] = True
853
879
  elif content.startswith("~~") and content.endswith("~~"):
854
880
  content = content[2:-2]
@@ -1066,8 +1092,6 @@ def convert_markdown_to_slack_payloads(
1066
1092
  payloads: List[Dict[str, Any]] = []
1067
1093
  for blocks in convert_markdown_to_slack_messages(markdown_text):
1068
1094
  fallback_text = build_fallback_text_from_blocks(blocks).strip()
1069
- if not fallback_text:
1070
- fallback_text = blocks_to_plain_text(blocks).strip()
1071
1095
  payloads.append({"blocks": blocks, "text": fallback_text or " "})
1072
1096
  return payloads
1073
1097
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-markdown-parser
3
- Version: 2.2.2
3
+ Version: 2.2.4
4
4
  Summary: Convert LLM Markdown into Slack Block Kit markdown/table messages
5
5
  Author: darkgaldragon
6
6
  License-Expression: MIT
@@ -562,6 +562,15 @@ def test_heading_with_inline_code_pipe_is_not_split() -> None:
562
562
  assert "Title" in blocks[0].get("text", "")
563
563
 
564
564
 
565
+ def test_heading_with_multi_backtick_inline_code_pipe_is_not_split() -> None:
566
+ raw = "# Title ``a|b``\n\nsome text\n"
567
+
568
+ blocks = convert_markdown_to_slack_blocks(raw)
569
+ assert len(blocks) == 1
570
+ assert all(b.get("type") == "markdown" for b in blocks)
571
+ assert blocks[0]["text"] == raw.strip()
572
+
573
+
565
574
  def test_code_fence_with_table_like_rows_stays_markdown() -> None:
566
575
  raw = """```
567
576
  a | b | c
@@ -596,3 +605,14 @@ def test_escaped_pipe_in_table_cell_strips_backslash() -> None:
596
605
  table = _first_table(convert_markdown_to_slack_blocks(raw))
597
606
  cell_text = extract_plain_text_from_table_cell(table["rows"][1][0])
598
607
  assert cell_text == "A | B"
608
+
609
+
610
+ def test_multi_backtick_code_span_keeps_pipe_inside_table_cell() -> None:
611
+ raw = "| A | B |\n|---|---|\n| left | ``x|y`` |\n"
612
+
613
+ table = _first_table(convert_markdown_to_slack_blocks(raw))
614
+ code_cell = table["rows"][1][1]["elements"][0]["elements"][0]
615
+
616
+ assert extract_plain_text_from_table_cell(table["rows"][1][1]) == "x|y"
617
+ assert code_cell["text"] == "x|y"
618
+ assert code_cell["style"] == {"code": True}