slack-markdown-parser 2.4.3__tar.gz → 2.4.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 (18) hide show
  1. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/CHANGELOG.md +7 -0
  2. {slack_markdown_parser-2.4.3/slack_markdown_parser.egg-info → slack_markdown_parser-2.4.4}/PKG-INFO +1 -1
  3. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/docs/spec.md +1 -0
  4. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/pyproject.toml +1 -1
  5. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/slack_markdown_parser/__init__.py +1 -1
  6. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/slack_markdown_parser/converter.py +134 -86
  7. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4/slack_markdown_parser.egg-info}/PKG-INFO +1 -1
  8. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/LICENSE +0 -0
  9. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/MANIFEST.in +0 -0
  10. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/README-ja.md +0 -0
  11. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/README.md +0 -0
  12. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/docs/spec-ja.md +0 -0
  13. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/setup.cfg +0 -0
  14. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/slack_markdown_parser/py.typed +0 -0
  15. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/slack_markdown_parser.egg-info/SOURCES.txt +0 -0
  16. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/slack_markdown_parser.egg-info/dependency_links.txt +0 -0
  17. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/slack_markdown_parser.egg-info/requires.txt +0 -0
  18. {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/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.4.4] - 2026-06-10
10
+
11
+ ### Fixed
12
+
13
+ - Stopped a link wrapped entirely in emphasis (`**[text](url)**`, and the `*`/`_`/`__`/`~~`/`***` variants) from rendering as dead, literal text in `rich_text` contexts such as list items and table cells. The emphasis token pattern matched the whole `**[text](url)**` span before the link pattern got a chance, so `_create_rich_text_inline_elements` emitted the inner `[text](url)` as a styled plain-text run and the link was never clickable. When a styled (non-code) token's inner content is itself a complete markdown link, it now becomes a `link` element carrying that style (e.g. a bold link), matching the already-working emphasis-inside-the-brackets form (`[**text**](url)`).
14
+ - Stopped Slack mention tokens from rendering as literal text inside promoted list items. Since 2.4.0 a simple list is emitted as a `rich_text` block, but the inline builder only tokenized links, code, and emphasis — so a `<#C123>` / `<@U123>` / `<!subteam^S123>` / `<!here>` token in a list item fell through as a plain `text` run. In a `rich_text` block a mention has to be a structured element (`channel`, `user`, `usergroup`, `broadcast`), so Slack showed the raw `<#C123>` rather than a pill. (Prose was unaffected: it stays in a `markdown` block, where Slack resolves the token itself.) These tokens are now converted to the matching rich_text elements, an optional `|label` display suffix is dropped (Slack renders the element from the id), and the plain-text fallback re-emits the canonical `<#C123>` token so a downgraded mrkdwn fallback still links and notifies. The same applies inside table cells: `extract_plain_text_from_table_cell` now delegates inline runs to the shared rich_text downgrade path instead of a separate near-copy, so a mention in a cell also survives into the fallback text rather than vanishing.
15
+
9
16
  ## [2.4.3] - 2026-05-29
10
17
 
11
18
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-markdown-parser
3
- Version: 2.4.3
3
+ Version: 2.4.4
4
4
  Summary: Convert LLM Markdown into Slack Block Kit messages
5
5
  Author: darkgaldragon
6
6
  License-Expression: MIT
@@ -81,6 +81,7 @@ Slack still controls when those newer features appear and how they look, so trea
81
81
  - simple one-level quotes to `rich_text_quote`
82
82
  - simple bullet and ordered lists to `rich_text_list`
83
83
  - Lists are promoted only when the list starts at the beginning of the text region or after a blank line, each non-blank line in the run is a list item, the list does not use ambiguous 1-3-space nested indentation, the item text does not rely on Markdown backslash escapes, and the run is not followed by an indented continuation paragraph.
84
+ - Slack mention tokens inside a promoted list item are converted to their structured `rich_text` elements — `<@U…>`/`<@W…>` to `user`, `<#C…>`/`<#G…>` to `channel`, `<!subteam^S…>` to `usergroup`, and `<!here>`/`<!channel>`/`<!everyone>` to `broadcast` — since a `rich_text` block does not resolve a raw token. An optional `|label` display suffix is dropped (Slack renders the element from the id).
84
85
  - Table-like rows inside fenced code blocks are kept out of table parsing
85
86
  - Internal blank lines can optionally be rewritten into placeholder lines so Slack keeps visible paragraph separation
86
87
  - Unsupported Slack angle-bracket tokens such as `<foo>` or raw HTML-like tags are neutralized
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "slack-markdown-parser"
7
- version = "2.4.3"
7
+ version = "2.4.4"
8
8
  description = "Convert LLM Markdown into Slack Block Kit 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.4.3"
3
+ __version__ = "2.4.4"
4
4
  __license__ = "MIT"
5
5
 
6
6
  from .converter import (
@@ -84,6 +84,12 @@ LOOSE_TABLE_SEPARATOR_PATTERN = re.compile(
84
84
  TABLE_TOKEN_PATTERN = re.compile(
85
85
  r"\[(?P<markdown_label>[^\]\n]+)\]\((?P<markdown_url>https?://[^\s)]+)\)"
86
86
  r"|<(?P<angle_url>https?://[^>\s|]+)(?:\|(?P<angle_label>[^>\n]+))?>"
87
+ # Slack mention tokens: user (<@U…>/<@W…>), channel (<#C…>/<#G…>), user
88
+ # group (<!subteam^S…>), and broadcast (<!here>/<!channel>/<!everyone>).
89
+ # An optional ``|label`` is the human-readable display the author saw; the
90
+ # rich_text element is rendered by Slack from the id, so the label is dropped.
91
+ r"|<(?P<mention>@[UW][A-Z0-9]+|#[CG][A-Z0-9]+|!subteam\^[A-Z0-9]+"
92
+ r"|!(?:here|channel|everyone))(?:\|[^>\n]+)?>"
87
93
  r"|(?P<token>"
88
94
  r"(?P<code>(?P<code_delimiter>`+)(?P<code_text>[^\n]+?)(?P=code_delimiter))"
89
95
  r"|~~[^~]+~~"
@@ -1317,6 +1323,40 @@ def looks_like_markdown_table(text: str) -> bool:
1317
1323
  return table_like_lines >= 2
1318
1324
 
1319
1325
 
1326
+ def _slack_mention_element(mention: str) -> dict[str, Any]:
1327
+ """Map a Slack mention token body to its rich_text element.
1328
+
1329
+ ``mention`` is the token interior without the angle brackets or ``|label``
1330
+ (e.g. ``@U123``, ``#C123``, ``!subteam^S123``, ``!here``). Slack renders
1331
+ these from the id alone, so no display text is carried.
1332
+ """
1333
+ sigil, body = mention[0], mention[1:]
1334
+ if sigil == "@":
1335
+ return {"type": "user", "user_id": body}
1336
+ if sigil == "#":
1337
+ return {"type": "channel", "channel_id": body}
1338
+ if body.startswith("subteam^"):
1339
+ return {"type": "usergroup", "usergroup_id": body[len("subteam^") :]}
1340
+ return {"type": "broadcast", "range": body}
1341
+
1342
+
1343
+ def _slack_mention_element_to_token(element: dict[str, Any]) -> str:
1344
+ """Inverse of :func:`_slack_mention_element` for plain-text fallbacks.
1345
+
1346
+ Emitting the canonical ``<#C…>`` / ``<@U…>`` token (rather than an empty
1347
+ string) keeps the mention live when a rich_text block is downgraded to a
1348
+ mrkdwn fallback, so it still links and notifies.
1349
+ """
1350
+ element_type = element.get("type")
1351
+ if element_type == "user":
1352
+ return f"<@{element.get('user_id', '')}>"
1353
+ if element_type == "channel":
1354
+ return f"<#{element.get('channel_id', '')}>"
1355
+ if element_type == "usergroup":
1356
+ return f"<!subteam^{element.get('usergroup_id', '')}>"
1357
+ return f"<!{element.get('range', '')}>"
1358
+
1359
+
1320
1360
  def _create_rich_text_inline_elements(
1321
1361
  text: str, *, empty_text: str = ""
1322
1362
  ) -> list[dict[str, Any]]:
@@ -1340,6 +1380,7 @@ def _create_rich_text_inline_elements(
1340
1380
  markdown_url = match.group("markdown_url")
1341
1381
  angle_url = match.group("angle_url")
1342
1382
  angle_label = match.group("angle_label")
1383
+ mention = match.group("mention")
1343
1384
  token = match.group("token") or ""
1344
1385
 
1345
1386
  if markdown_label and markdown_url:
@@ -1350,6 +1391,8 @@ def _create_rich_text_inline_elements(
1350
1391
  "url": angle_url,
1351
1392
  "text": angle_label or angle_url,
1352
1393
  }
1394
+ elif mention:
1395
+ element = _slack_mention_element(mention)
1353
1396
  else:
1354
1397
  style: dict[str, bool] = {}
1355
1398
  content = token
@@ -1368,9 +1411,26 @@ def _create_rich_text_inline_elements(
1368
1411
  content = content[1:-1]
1369
1412
  style["italic"] = True
1370
1413
 
1371
- element = {"type": "text", "text": content}
1372
- if style:
1373
- element["style"] = style
1414
+ # A link wrapped entirely in emphasis (``**[text](url)**``) is matched
1415
+ # by the emphasis branch above, not the link branch, so its inner
1416
+ # content is a bare ``[text](url)``. Emit a styled ``link`` element
1417
+ # rather than a literal text run, otherwise the link is dead in Slack.
1418
+ inner_link = (
1419
+ TABLE_TOKEN_PATTERN.fullmatch(content)
1420
+ if style and not style.get("code")
1421
+ else None
1422
+ )
1423
+ if inner_link is not None and inner_link.group("markdown_url"):
1424
+ element = {
1425
+ "type": "link",
1426
+ "url": inner_link.group("markdown_url"),
1427
+ "text": inner_link.group("markdown_label"),
1428
+ "style": style,
1429
+ }
1430
+ else:
1431
+ element = {"type": "text", "text": content}
1432
+ if style:
1433
+ element["style"] = style
1374
1434
  elements.append(element)
1375
1435
  last_index = match.end()
1376
1436
 
@@ -1410,12 +1470,11 @@ def extract_plain_text_from_table_cell(cell: dict[str, Any]) -> str:
1410
1470
  if not isinstance(element, dict):
1411
1471
  continue
1412
1472
  if element.get("type") == "rich_text_section":
1413
- for child in element.get("elements", []):
1414
- if isinstance(child, dict):
1415
- if child.get("type") == "link":
1416
- texts.append(str(child.get("text") or child.get("url", "")))
1417
- else:
1418
- texts.append(child.get("text", ""))
1473
+ texts.append(
1474
+ _rich_text_inline_elements_to_plain_text(
1475
+ element.get("elements", [])
1476
+ )
1477
+ )
1419
1478
  elif "text" in element:
1420
1479
  texts.append(str(element.get("text", "")))
1421
1480
  return "".join(texts)
@@ -1482,6 +1541,8 @@ def _rich_text_inline_elements_to_plain_text(elements: list[dict[str, Any]]) ->
1482
1541
  element_type = element.get("type")
1483
1542
  if element_type == "link":
1484
1543
  texts.append(str(element.get("text") or element.get("url", "")))
1544
+ elif element_type in {"user", "channel", "usergroup", "broadcast"}:
1545
+ texts.append(_slack_mention_element_to_token(element))
1485
1546
  else:
1486
1547
  texts.append(str(element.get("text", "")))
1487
1548
  return "".join(texts)
@@ -2017,44 +2078,73 @@ def convert_markdown_to_slack_payloads(
2017
2078
  return payloads
2018
2079
 
2019
2080
 
2020
- def blocks_to_plain_text(blocks: list[dict[str, Any]]) -> str:
2021
- """Build plain text representation from Slack blocks."""
2081
+ def _markdown_block_to_plain_text(block: dict[str, Any]) -> str:
2082
+ """Downgrade a ``markdown`` block, preferring the build-time annotation."""
2083
+ text = getattr(block, "_plain_text", None) or ""
2084
+ if text:
2085
+ return str(text)
2086
+ raw_text = block.get("text", "")
2087
+ if not raw_text:
2088
+ return ""
2089
+ return _normalize_markdown_block_plain_text(
2090
+ _strip_synthetic_blank_line_placeholders(
2091
+ _strip_synthetic_spaces_from_plain_text(
2092
+ strip_zero_width_spaces(raw_text),
2093
+ getattr(block, "_synthetic_space_indices", None),
2094
+ ),
2095
+ getattr(block, "_synthetic_blank_line_indices", None),
2096
+ )
2097
+ )
2098
+
2099
+
2100
+ def _blocks_to_downgrade_parts(
2101
+ blocks: list[dict[str, Any]], *, fallback: bool
2102
+ ) -> list[str]:
2103
+ """Shared block walker behind :func:`blocks_to_plain_text` and
2104
+ :func:`build_fallback_text_from_blocks`.
2105
+
2106
+ The two public functions intentionally differ in a few policies, kept
2107
+ explicit on the ``fallback`` flag: fallback keeps table cells verbatim
2108
+ (empty cells preserve column alignment) and emits a whole table as one
2109
+ part, while the plain-text view strips zero-width spaces, drops empty
2110
+ cells, emits one part per row, and surfaces ``text`` from unknown blocks.
2111
+ """
2022
2112
  parts: list[str] = []
2023
2113
 
2024
2114
  for block in blocks or []:
2025
- block_type = block.get("type") if isinstance(block, dict) else None
2115
+ if not isinstance(block, dict):
2116
+ continue
2117
+ block_type = block.get("type")
2026
2118
 
2027
2119
  if block_type == "markdown":
2028
- text = getattr(block, "_plain_text", None) or ""
2029
- if not text:
2030
- raw_text = block.get("text", "")
2031
- if raw_text:
2032
- text = _normalize_markdown_block_plain_text(
2033
- _strip_synthetic_blank_line_placeholders(
2034
- _strip_synthetic_spaces_from_plain_text(
2035
- strip_zero_width_spaces(raw_text),
2036
- getattr(block, "_synthetic_space_indices", None),
2037
- ),
2038
- getattr(block, "_synthetic_blank_line_indices", None),
2039
- )
2040
- )
2041
- if text:
2120
+ text = _markdown_block_to_plain_text(block)
2121
+ if text.strip() if fallback else text:
2042
2122
  parts.append(text)
2043
2123
  elif block_type == "table":
2044
- rows = block.get("rows") or []
2045
- for row in rows:
2046
- cell_texts: list[str] = []
2124
+ row_texts: list[str] = []
2125
+ for row in block.get("rows") or []:
2047
2126
  if not isinstance(row, list):
2048
2127
  continue
2049
- for cell in row:
2050
- cell_text = extract_plain_text_from_table_cell(cell)
2051
- if cell_text:
2052
- cell_texts.append(strip_zero_width_spaces(cell_text))
2128
+ if fallback:
2129
+ cell_texts = [
2130
+ extract_plain_text_from_table_cell(cell) for cell in row
2131
+ ]
2132
+ else:
2133
+ cell_texts = []
2134
+ for cell in row:
2135
+ cell_text = extract_plain_text_from_table_cell(cell)
2136
+ if cell_text:
2137
+ cell_texts.append(strip_zero_width_spaces(cell_text))
2053
2138
  if cell_texts:
2054
- parts.append(" | ".join(cell_texts))
2139
+ row_texts.append(" | ".join(cell_texts))
2140
+ if fallback:
2141
+ if row_texts:
2142
+ parts.append("\n".join(row_texts))
2143
+ else:
2144
+ parts.extend(row_texts)
2055
2145
  elif block_type == "rich_text":
2056
2146
  text = _rich_text_block_to_plain_text(block)
2057
- if text:
2147
+ if text.strip() if fallback else text:
2058
2148
  parts.append(text)
2059
2149
  elif block_type == "header":
2060
2150
  text = block.get("text", {})
@@ -2070,66 +2160,24 @@ def blocks_to_plain_text(blocks: list[dict[str, Any]]) -> str:
2070
2160
  parts.append(image_text)
2071
2161
  elif block_type == "divider":
2072
2162
  parts.append(getattr(block, "_plain_text", None) or "---")
2073
- elif isinstance(block, dict):
2163
+ elif not fallback:
2074
2164
  text = block.get("text", "")
2075
2165
  if text:
2076
2166
  parts.append(str(text))
2077
2167
 
2168
+ return parts
2169
+
2170
+
2171
+ def blocks_to_plain_text(blocks: list[dict[str, Any]]) -> str:
2172
+ """Build plain text representation from Slack blocks."""
2173
+ parts = _blocks_to_downgrade_parts(blocks, fallback=False)
2078
2174
  return "\n".join([p for p in parts if p]).strip()
2079
2175
 
2080
2176
 
2081
2177
  def build_fallback_text_from_blocks(blocks: list[dict[str, Any]]) -> str:
2082
2178
  """Build Slack fallback text from block structure."""
2083
- plain_parts: list[str] = []
2084
-
2085
- for block in blocks or []:
2086
- if not isinstance(block, dict):
2087
- continue
2088
-
2089
- if block.get("type") == "markdown":
2090
- text = getattr(block, "_plain_text", None) or ""
2091
- if not text:
2092
- text = _normalize_markdown_block_plain_text(
2093
- _strip_synthetic_blank_line_placeholders(
2094
- _strip_synthetic_spaces_from_plain_text(
2095
- strip_zero_width_spaces(block.get("text", "")),
2096
- getattr(block, "_synthetic_space_indices", None),
2097
- ),
2098
- getattr(block, "_synthetic_blank_line_indices", None),
2099
- ),
2100
- )
2101
- if text.strip():
2102
- plain_parts.append(text)
2103
- elif block.get("type") == "table":
2104
- table_lines: list[str] = []
2105
- for row in block.get("rows", []):
2106
- if not isinstance(row, list):
2107
- continue
2108
- cells = [extract_plain_text_from_table_cell(cell) for cell in row]
2109
- if cells:
2110
- table_lines.append(" | ".join(cells))
2111
- if table_lines:
2112
- plain_parts.append("\n".join(table_lines))
2113
- elif block.get("type") == "rich_text":
2114
- text = _rich_text_block_to_plain_text(block)
2115
- if text.strip():
2116
- plain_parts.append(text)
2117
- elif block.get("type") == "header":
2118
- text = block.get("text", {})
2119
- if isinstance(text, dict) and text.get("text"):
2120
- plain_parts.append(str(text.get("text", "")))
2121
- elif block.get("type") == "image":
2122
- alt_text = str(block.get("alt_text", "")).strip()
2123
- image_url = str(block.get("image_url", "")).strip()
2124
- image_text = alt_text or image_url
2125
- if alt_text and image_url:
2126
- image_text = f"{alt_text} ({image_url})"
2127
- if image_text:
2128
- plain_parts.append(image_text)
2129
- elif block.get("type") == "divider":
2130
- plain_parts.append(getattr(block, "_plain_text", None) or "---")
2131
-
2132
- return "\n\n".join([part for part in plain_parts if part.strip()])
2179
+ parts = _blocks_to_downgrade_parts(blocks, fallback=True)
2180
+ return "\n\n".join([part for part in parts if part.strip()])
2133
2181
 
2134
2182
 
2135
2183
  # Backward-compatible helper retained for existing imports.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slack-markdown-parser
3
- Version: 2.4.3
3
+ Version: 2.4.4
4
4
  Summary: Convert LLM Markdown into Slack Block Kit messages
5
5
  Author: darkgaldragon
6
6
  License-Expression: MIT