Skip to content

Commit 693bb24

Browse files
authored
✨ Add gfm-like2 preset with task lists, alerts, and single-tilde strikethrough (#388)
### Summary Adds a new `gfm-like2` preset that extends `gfm-like` with three GFM features: - **Task lists** — `- [x] done` / `- [ ] todo` checkbox syntax in list items - **Alerts** — `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, etc. inside blockquotes - **Single-tilde strikethrough** — `~text~` in addition to `~~text~~` These are enabled via the `gfm-like2` preset or individually through the `tasklists`, `alerts`, and `strikethrough_single_tilde` options. The existing `gfm-like` preset is unchanged, so as to remain back-compatible. ### Why in markdown-it-py, not mdit-py-plugins? Task lists and alerts are implemented by integrating detection directly into the existing block-level parsers (list.py and blockquote.py), rather than as post-processing rules: - Checkbox detection happens during list item parsing, before the sub-parser runs on the item content - Alert detection happens during blockquote parsing, before the inner content is tokenized This design is not achievable from a plugin: plugins can only add new rules or post-process the token stream — they cannot modify the internals of `list_block()` or `blockquote()` to inject detection at the right point in the parsing pipeline. Implementing these as post-processing core rules would work functionally, but it means re-walking and mutating the token stream after the fact, which is less clean and less consistent with how the block parsers are designed to work. Single-tilde strikethrough similarly extends the existing strikethrough rule's matching logic (opener/closer width matching), which is more naturally done inside the rule than bolted on externally. ### Changes - **`rules_block/list.py`** — Detect `[ ]`/`[x]`/`[X]` at content start during list item parsing; set `token.meta["checked"]`; advance `bMarks` past the checkbox; add CSS classes (`task-list-item`, `contains-task-list`) after the list loop - **`rules_block/blockquote.py`** — Detect `[!TYPE]` on the first content line; emit `alert_open`/`alert_close` + title tokens instead of `blockquote_open`/`blockquote_close`; skip the marker line during tokenization - **`rules_inline/strikethrough.py`** — When `strikethrough_single_tilde` is enabled, accept 1 or 2 tildes (reject 3+); enforce opener/closer width matching in `_postProcess` - **renderer.py** — Add `list_item_open` render method that injects checkbox HTML when `meta["checked"]` is present - **`presets/__init__.py`** — Add `gfm_like2` preset class - **`main.py`** — Register `gfm-like2` in `_PRESETS` - **utils.py** — Add `tasklists`, `alerts`, `strikethrough_single_tilde` keys to `OptionsType` - **pyproject.toml** — Add `pytest-timeout` to test deps; set 10s default timeout - **Test fixtures** — 11 tasklist cases, 15 alert cases, 13 single-tilde strikethrough cases ### Usage ```python from markdown_it import MarkdownIt md = MarkdownIt("gfm-like2") md.render("- [x] done\n- [ ] todo") md.render("> [!NOTE]\n> This is a note.") md.render("~strikethrough~") ```
1 parent df6fd36 commit 693bb24

15 files changed

Lines changed: 707 additions & 24 deletions

File tree

docs/plugins.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ The following plugins are embedded within the core package:
66

77
- [tables](https://help.github.com/articles/organizing-information-with-tables/) (GFM)
88
- [strikethrough](https://help.github.com/articles/basic-writing-and-formatting-syntax/#styling-text) (GFM)
9+
- [task lists](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-task-lists) (GFM) — `- [x] done`
10+
- [alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) (GFM) — `> [!NOTE]`
911

1012
These can be enabled individually:
1113

@@ -18,7 +20,8 @@ or as part of a configuration:
1820

1921
```python
2022
from markdown_it import MarkdownIt
21-
md = MarkdownIt("gfm-like")
23+
md = MarkdownIt("gfm-like") # tables, strikethrough, linkify
24+
md = MarkdownIt("gfm-like2") # + task lists, alerts, single-tilde strikethrough
2225
```
2326

2427
```{seealso}

docs/using.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ You can define this configuration *via* directly supplying a dictionary or a pre
5858
- `gfm-like`: This configures the parser to approximately comply with the [GitHub Flavored Markdown specification](https://github.github.com/gfm/).
5959
Compared to `commonmark`, it enables the table, strikethrough and linkify components.
6060
**Important**, to use this configuration you must have `linkify-it-py` installed.
61+
- `gfm-like2`: Builds on `gfm-like` and additionally enables task lists (`- [x] done`),
62+
GitHub-style alerts (`> [!NOTE]`), and single-tilde strikethrough (`~text~`).
63+
**Important**, to use this configuration you must have `linkify-it-py` installed.
6164

6265
```{jupyter-execute}
6366
from markdown_it.presets import zero

markdown_it/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"zero": presets.zero.make(),
2727
"commonmark": presets.commonmark.make(),
2828
"gfm-like": presets.gfm_like.make(),
29+
"gfm-like2": presets.gfm_like2.make(),
2930
}
3031

3132

markdown_it/presets/__init__.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__all__ = ("commonmark", "default", "gfm_like", "js_default", "zero")
1+
__all__ = ("commonmark", "default", "gfm_like", "gfm_like2", "js_default", "zero")
22

33
from ..utils import PresetType
44
from . import commonmark, default, zero
@@ -26,3 +26,23 @@ def make() -> PresetType:
2626
config["options"]["linkify"] = True
2727
config["options"]["html"] = True
2828
return config
29+
30+
31+
class gfm_like2: # noqa: N801
32+
"""GitHub Flavoured Markdown (GFM) like, extended.
33+
34+
Builds on ``gfm-like`` and additionally enables:
35+
36+
- Task lists (``- [x] done``)
37+
- Alerts (``> [!NOTE]``)
38+
- Single-tilde strikethrough (``~text~`` in addition to ``~~text~~``)
39+
"""
40+
41+
@staticmethod
42+
def make() -> PresetType:
43+
config = gfm_like.make()
44+
config["options"]["tasklists"] = True
45+
config["options"]["tasklists_editable"] = False
46+
config["options"]["alerts"] = True
47+
config["options"]["strikethrough_single_tilde"] = True
48+
return config

markdown_it/renderer.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,26 @@ def renderInlineAsText(
209209

210210
###################################################
211211

212+
def list_item_open(
213+
self,
214+
tokens: Sequence[Token],
215+
idx: int,
216+
options: OptionsDict,
217+
env: EnvType,
218+
) -> str:
219+
token = tokens[idx]
220+
result = self.renderToken(tokens, idx, options, env)
221+
if token.meta and "checked" in token.meta:
222+
checked_attr = ' checked=""' if token.meta["checked"] else ""
223+
disabled_attr = (
224+
"" if options.get("tasklists_editable", False) else ' disabled=""'
225+
)
226+
result += (
227+
'<input class="task-list-item-checkbox"'
228+
f'{disabled_attr} type="checkbox"{checked_attr}> '
229+
)
230+
return result
231+
212232
def code_inline(
213233
self, tokens: Sequence[Token], idx: int, options: OptionsDict, env: EnvType
214234
) -> str:

markdown_it/rules_block/blockquote.py

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -273,17 +273,58 @@ def blockquote(state: StateBlock, startLine: int, endLine: int, silent: bool) ->
273273
oldIndent = state.blkIndent
274274
state.blkIndent = 0
275275

276-
token = state.push("blockquote_open", "blockquote", 1)
277-
token.markup = ">"
278-
token.map = lines = [startLine, 0]
276+
# Detect GitHub-style alert marker on the first content line.
277+
# Note: `startLine` here refers to the first content line of the
278+
# blockquote, after the `>` prefix has already been stripped by the
279+
# blockquote parser above (bMarks/tShift adjusted to skip `> `).
280+
alert_kind = None
281+
if state.md.options.get("alerts", False) and nextLine > startLine:
282+
alert_kind = _detect_alert(state, startLine)
283+
284+
lines = [startLine, 0]
285+
286+
if alert_kind is not None:
287+
# Emit alert tokens instead of blockquote tokens
288+
alert_lower = alert_kind.lower()
289+
token = state.push("alert_open", "div", 1)
290+
token.markup = ">"
291+
token.attrSet("class", f"markdown-alert markdown-alert-{alert_lower}")
292+
token.map = lines
293+
token.info = alert_kind
294+
token.meta = {"kind": alert_kind}
295+
296+
# Emit a title paragraph: <p class="markdown-alert-title">Kind</p>
297+
token = state.push("alert_title_open", "p", 1)
298+
token.attrSet("class", "markdown-alert-title")
299+
title_token = state.push("inline", "", 0)
300+
title_token.content = alert_kind.capitalize()
301+
title_token.children = []
302+
token = state.push("alert_title_close", "p", -1)
303+
304+
# Skip the marker line (startLine) and tokenize from startLine + 1.
305+
contentStart = startLine + 1
306+
if contentStart < nextLine:
307+
# tokenize() updates state.line to nextLine as part of its
308+
# contract, consistent with the blockquote code path below.
309+
state.md.block.tokenize(state, contentStart, nextLine)
310+
else:
311+
state.line = nextLine
312+
313+
token = state.push("alert_close", "div", -1)
314+
token.markup = ">"
315+
else:
316+
token = state.push("blockquote_open", "blockquote", 1)
317+
token.markup = ">"
318+
token.map = lines
279319

280-
state.md.block.tokenize(state, startLine, nextLine)
320+
state.md.block.tokenize(state, startLine, nextLine)
281321

282-
token = state.push("blockquote_close", "blockquote", -1)
283-
token.markup = ">"
322+
token = state.push("blockquote_close", "blockquote", -1)
323+
token.markup = ">"
284324

285325
state.lineMax = oldLineMax
286326
state.parentType = oldParentType
327+
# Update the opening token map for both alert and blockquote containers.
287328
lines[1] = state.line
288329

289330
# Restore original tShift; this might not be necessary since the parser
@@ -297,3 +338,31 @@ def blockquote(state: StateBlock, startLine: int, endLine: int, silent: bool) ->
297338
state.blkIndent = oldIndent
298339

299340
return True
341+
342+
343+
_ALERT_TYPES = {"NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"}
344+
345+
346+
def _detect_alert(state: StateBlock, startLine: int) -> str | None:
347+
"""Detect ``[!TYPE]`` on *startLine* (after ``>`` prefix has been stripped).
348+
349+
Returns the alert type string (e.g. ``"NOTE"``) or ``None``.
350+
"""
351+
pos = state.bMarks[startLine] + state.tShift[startLine]
352+
maximum = state.eMarks[startLine]
353+
src = state.src
354+
355+
# Trim trailing whitespace
356+
while maximum > pos and src[maximum - 1] in (" ", "\t"):
357+
maximum -= 1
358+
359+
if maximum - pos < 4:
360+
return None
361+
if src[pos] != "[" or src[pos + 1] != "!":
362+
return None
363+
if src[maximum - 1] != "]":
364+
return None
365+
type_str = src[pos + 2 : maximum - 1].upper()
366+
if type_str not in _ALERT_TYPES:
367+
return None
368+
return type_str

markdown_it/rules_block/list.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,20 @@ def list_block(state: StateBlock, startLine: int, endLine: int, silent: bool) ->
235235
if isOrdered:
236236
token.info = state.src[start : posAfterMarker - 1]
237237

238+
# Detect GFM task checkbox: `[ ] ` or `[x] `/`[X] ` at content start
239+
checkboxLen = 0
240+
if state.md.options.get("tasklists", False) and contentStart < maximum:
241+
checked = _detect_task_checkbox(state.src, contentStart, maximum)
242+
if checked is not None:
243+
token.meta = {"checked": checked}
244+
# Advance content past the checkbox: `[x]` (3 chars) + whitespace.
245+
# `_detect_task_checkbox` already guarantees a whitespace char at
246+
# pos+3, so we always consume 4 characters.
247+
checkboxLen = 4
248+
238249
# change current state, then restore it after parser subcall
239250
oldTight = state.tight
251+
oldBMark = state.bMarks[startLine]
240252
oldTShift = state.tShift[startLine]
241253
oldSCount = state.sCount[startLine]
242254

@@ -252,6 +264,12 @@ def list_block(state: StateBlock, startLine: int, endLine: int, silent: bool) ->
252264
state.tShift[startLine] = contentStart - state.bMarks[startLine]
253265
state.sCount[startLine] = offset
254266

267+
# If we detected a checkbox, advance bMarks past it so that
268+
# getLines() doesn't include the checkbox text in the content.
269+
if checkboxLen:
270+
state.bMarks[startLine] = contentStart + checkboxLen
271+
state.tShift[startLine] = 0
272+
255273
if contentStart >= maximum and state.isEmpty(startLine + 1):
256274
# workaround for this case
257275
# (list item is empty, list terminates before "foo"):
@@ -277,6 +295,8 @@ def list_block(state: StateBlock, startLine: int, endLine: int, silent: bool) ->
277295

278296
state.blkIndent = state.listIndent
279297
state.listIndent = oldListIndent
298+
if checkboxLen:
299+
state.bMarks[startLine] = oldBMark
280300
state.tShift[startLine] = oldTShift
281301
state.sCount[startLine] = oldSCount
282302
state.tight = oldTight
@@ -326,6 +346,24 @@ def list_block(state: StateBlock, startLine: int, endLine: int, silent: bool) ->
326346
break
327347

328348
# Finalize list
349+
350+
# If any direct list item has a task checkbox, add class to the list
351+
if state.md.options.get("tasklists", False):
352+
containsTask = False
353+
level = state.tokens[listTokIdx].level
354+
for j in range(listTokIdx + 1, len(state.tokens)):
355+
tok = state.tokens[j]
356+
if (
357+
tok.level == level + 1
358+
and tok.type == "list_item_open"
359+
and tok.meta
360+
and "checked" in tok.meta
361+
):
362+
tok.attrJoin("class", "task-list-item")
363+
containsTask = True
364+
if containsTask:
365+
state.tokens[listTokIdx].attrJoin("class", "contains-task-list")
366+
329367
if isOrdered:
330368
token = state.push("ordered_list_close", "ol", -1)
331369
else:
@@ -343,3 +381,28 @@ def list_block(state: StateBlock, startLine: int, endLine: int, silent: bool) ->
343381
markTightParagraphs(state, listTokIdx)
344382

345383
return True
384+
385+
386+
def _detect_task_checkbox(src: str, pos: int, maximum: int) -> bool | None:
387+
"""Detect ``[ ]``, ``[x]``, or ``[X]`` at *pos*, followed by whitespace.
388+
389+
Returns ``True`` (checked), ``False`` (unchecked), or ``None`` (no match).
390+
"""
391+
# Need at least 4 chars: `[`, char, `]`, whitespace
392+
if pos + 4 > maximum:
393+
return None
394+
if src[pos] != "[":
395+
return None
396+
inner = src[pos + 1]
397+
if src[pos + 2] != "]":
398+
return None
399+
if inner == " ":
400+
checked = False
401+
elif inner in ("x", "X"):
402+
checked = True
403+
else:
404+
return None
405+
# After `]`, must have whitespace
406+
if src[pos + 3] not in (" ", "\t"):
407+
return None
408+
return checked

0 commit comments

Comments
 (0)