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.
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/CHANGELOG.md +7 -0
- {slack_markdown_parser-2.4.3/slack_markdown_parser.egg-info → slack_markdown_parser-2.4.4}/PKG-INFO +1 -1
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/docs/spec.md +1 -0
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/pyproject.toml +1 -1
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/slack_markdown_parser/__init__.py +1 -1
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/slack_markdown_parser/converter.py +134 -86
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4/slack_markdown_parser.egg-info}/PKG-INFO +1 -1
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/LICENSE +0 -0
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/MANIFEST.in +0 -0
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/README-ja.md +0 -0
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/README.md +0 -0
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/docs/spec-ja.md +0 -0
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/setup.cfg +0 -0
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/slack_markdown_parser/py.typed +0 -0
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/slack_markdown_parser.egg-info/SOURCES.txt +0 -0
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/slack_markdown_parser.egg-info/dependency_links.txt +0 -0
- {slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/slack_markdown_parser.egg-info/requires.txt +0 -0
- {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
|
|
@@ -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
|
{slack_markdown_parser-2.4.3 → slack_markdown_parser-2.4.4}/slack_markdown_parser/converter.py
RENAMED
|
@@ -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
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
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
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
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
|
|
2021
|
-
"""
|
|
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
|
-
|
|
2115
|
+
if not isinstance(block, dict):
|
|
2116
|
+
continue
|
|
2117
|
+
block_type = block.get("type")
|
|
2026
2118
|
|
|
2027
2119
|
if block_type == "markdown":
|
|
2028
|
-
text =
|
|
2029
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|