From b7bfb9ab18281e29359565698af0b1f6ab610852 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Sun, 3 May 2026 13:16:28 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Add=20`gfm-like2`=20preset=20wi?= =?UTF-8?q?th=20task=20lists,=20alerts,=20and=20single-tilde=20strikethrou?= =?UTF-8?q?gh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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. This mirrors [the approach taken in ubc_parser_md](https://github.com/useblocks/ubcode/tree/main/rust/ubc_parser_md) (the Rust markdown-it port), where: - 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~") ``` --- markdown_it/main.py | 1 + markdown_it/presets/__init__.py | 21 ++- markdown_it/renderer.py | 17 ++ markdown_it/rules_block/blockquote.py | 73 +++++++- markdown_it/rules_block/list.py | 66 +++++++ markdown_it/rules_inline/strikethrough.py | 75 ++++++-- markdown_it/utils.py | 6 + pyproject.toml | 2 + tests/test_port/fixtures/alerts.md | 172 ++++++++++++++++++ .../fixtures/strikethrough_single_tilde.md | 90 +++++++++ tests/test_port/fixtures/tasklists.md | 133 ++++++++++++++ tests/test_port/test_fixtures.py | 30 +++ 12 files changed, 664 insertions(+), 22 deletions(-) create mode 100644 tests/test_port/fixtures/alerts.md create mode 100644 tests/test_port/fixtures/strikethrough_single_tilde.md create mode 100644 tests/test_port/fixtures/tasklists.md diff --git a/markdown_it/main.py b/markdown_it/main.py index bf9fd18f..90289730 100644 --- a/markdown_it/main.py +++ b/markdown_it/main.py @@ -26,6 +26,7 @@ "zero": presets.zero.make(), "commonmark": presets.commonmark.make(), "gfm-like": presets.gfm_like.make(), + "gfm-like2": presets.gfm_like2.make(), } diff --git a/markdown_it/presets/__init__.py b/markdown_it/presets/__init__.py index e21c7806..76d38d61 100644 --- a/markdown_it/presets/__init__.py +++ b/markdown_it/presets/__init__.py @@ -1,4 +1,4 @@ -__all__ = ("commonmark", "default", "gfm_like", "js_default", "zero") +__all__ = ("commonmark", "default", "gfm_like", "gfm_like2", "js_default", "zero") from ..utils import PresetType from . import commonmark, default, zero @@ -26,3 +26,22 @@ def make() -> PresetType: config["options"]["linkify"] = True config["options"]["html"] = True return config + + +class gfm_like2: # noqa: N801 + """GitHub Flavoured Markdown (GFM) like, extended. + + Builds on ``gfm-like`` and additionally enables: + + - Task lists (``- [x] done``) + - Alerts (``> [!NOTE]``) + - Single-tilde strikethrough (``~text~`` in addition to ``~~text~~``) + """ + + @staticmethod + def make() -> PresetType: + config = gfm_like.make() + config["options"]["tasklists"] = True + config["options"]["alerts"] = True + config["options"]["strikethrough_single_tilde"] = True + return config diff --git a/markdown_it/renderer.py b/markdown_it/renderer.py index 6d60589a..094e9f00 100644 --- a/markdown_it/renderer.py +++ b/markdown_it/renderer.py @@ -209,6 +209,23 @@ def renderInlineAsText( ################################################### + def list_item_open( + self, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, + ) -> str: + token = tokens[idx] + result = self.renderToken(tokens, idx, options, env) + if token.meta and "checked" in token.meta: + checked_attr = ' checked=""' if token.meta["checked"] else "" + result += ( + ' ' + ) + return result + def code_inline( self, tokens: Sequence[Token], idx: int, options: OptionsDict, env: EnvType ) -> str: diff --git a/markdown_it/rules_block/blockquote.py b/markdown_it/rules_block/blockquote.py index 0c9081b9..e38bafb2 100644 --- a/markdown_it/rules_block/blockquote.py +++ b/markdown_it/rules_block/blockquote.py @@ -273,14 +273,47 @@ def blockquote(state: StateBlock, startLine: int, endLine: int, silent: bool) -> oldIndent = state.blkIndent state.blkIndent = 0 - token = state.push("blockquote_open", "blockquote", 1) - token.markup = ">" - token.map = lines = [startLine, 0] + # Detect GitHub-style alert marker on the first content line. + alert_kind = None + if state.md.options.get("alerts", False) and nextLine > startLine: + alert_kind = _detect_alert(state, startLine) + + if alert_kind is not None: + # Emit alert tokens instead of blockquote tokens + alert_lower = alert_kind.lower() + token = state.push("alert_open", "div", 1) + token.markup = ">" + token.attrSet("class", f"markdown-alert markdown-alert-{alert_lower}") + token.map = lines = [startLine, 0] + token.info = alert_kind + token.meta = {"kind": alert_kind} + + # Emit a title paragraph:

Kind

+ token = state.push("alert_title_open", "p", 1) + token.attrSet("class", "markdown-alert-title") + title_token = state.push("inline", "", 0) + title_token.content = alert_kind.capitalize() + title_token.children = [] + token = state.push("alert_title_close", "p", -1) + + # Skip the marker line (startLine) and tokenize from startLine + 1 + contentStart = startLine + 1 + if contentStart < nextLine: + state.md.block.tokenize(state, contentStart, nextLine) + else: + state.line = nextLine + + token = state.push("alert_close", "div", -1) + token.markup = ">" + else: + token = state.push("blockquote_open", "blockquote", 1) + token.markup = ">" + token.map = lines = [startLine, 0] - state.md.block.tokenize(state, startLine, nextLine) + state.md.block.tokenize(state, startLine, nextLine) - token = state.push("blockquote_close", "blockquote", -1) - token.markup = ">" + token = state.push("blockquote_close", "blockquote", -1) + token.markup = ">" state.lineMax = oldLineMax state.parentType = oldParentType @@ -297,3 +330,31 @@ def blockquote(state: StateBlock, startLine: int, endLine: int, silent: bool) -> state.blkIndent = oldIndent return True + + +_ALERT_TYPES = {"NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"} + + +def _detect_alert(state: StateBlock, startLine: int) -> str | None: + """Detect ``[!TYPE]`` on *startLine* (after ``>`` prefix has been stripped). + + Returns the alert type string (e.g. ``"NOTE"``) or ``None``. + """ + pos = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + src = state.src + + # Trim trailing whitespace + while maximum > pos and src[maximum - 1] in (" ", "\t"): + maximum -= 1 + + if maximum - pos < 4: + return None + if src[pos] != "[" or src[pos + 1] != "!": + return None + if src[maximum - 1] != "]": + return None + type_str = src[pos + 2 : maximum - 1].upper() + if type_str not in _ALERT_TYPES: + return None + return type_str diff --git a/markdown_it/rules_block/list.py b/markdown_it/rules_block/list.py index d8070d74..c0eba07a 100644 --- a/markdown_it/rules_block/list.py +++ b/markdown_it/rules_block/list.py @@ -235,8 +235,23 @@ def list_block(state: StateBlock, startLine: int, endLine: int, silent: bool) -> if isOrdered: token.info = state.src[start : posAfterMarker - 1] + # Detect GFM task checkbox: `[ ] ` or `[x] `/`[X] ` at content start + checkboxLen = 0 + if state.md.options.get("tasklists", False) and contentStart < maximum: + checked = _detect_task_checkbox(state.src, contentStart, maximum) + if checked is not None: + token.meta = {"checked": checked} + # Advance content past the checkbox (3 chars `[x]`) + one whitespace + checkboxLen = 3 + if (contentStart + 3) < maximum and state.src[contentStart + 3] in ( + " ", + "\t", + ): + checkboxLen = 4 + # change current state, then restore it after parser subcall oldTight = state.tight + oldBMark = state.bMarks[startLine] oldTShift = state.tShift[startLine] oldSCount = state.sCount[startLine] @@ -252,6 +267,12 @@ def list_block(state: StateBlock, startLine: int, endLine: int, silent: bool) -> state.tShift[startLine] = contentStart - state.bMarks[startLine] state.sCount[startLine] = offset + # If we detected a checkbox, advance bMarks past it so that + # getLines() doesn't include the checkbox text in the content. + if checkboxLen: + state.bMarks[startLine] = contentStart + checkboxLen + state.tShift[startLine] = 0 + if contentStart >= maximum and state.isEmpty(startLine + 1): # workaround for this case # (list item is empty, list terminates before "foo"): @@ -277,6 +298,8 @@ def list_block(state: StateBlock, startLine: int, endLine: int, silent: bool) -> state.blkIndent = state.listIndent state.listIndent = oldListIndent + if checkboxLen: + state.bMarks[startLine] = oldBMark state.tShift[startLine] = oldTShift state.sCount[startLine] = oldSCount state.tight = oldTight @@ -326,6 +349,24 @@ def list_block(state: StateBlock, startLine: int, endLine: int, silent: bool) -> break # Finalize list + + # If any direct list item has a task checkbox, add class to the list + if state.md.options.get("tasklists", False): + containsTask = False + level = state.tokens[listTokIdx].level + for j in range(listTokIdx + 1, len(state.tokens)): + tok = state.tokens[j] + if ( + tok.level == level + 1 + and tok.type == "list_item_open" + and tok.meta + and "checked" in tok.meta + ): + tok.attrJoin("class", "task-list-item") + containsTask = True + if containsTask: + state.tokens[listTokIdx].attrJoin("class", "contains-task-list") + if isOrdered: token = state.push("ordered_list_close", "ol", -1) else: @@ -343,3 +384,28 @@ def list_block(state: StateBlock, startLine: int, endLine: int, silent: bool) -> markTightParagraphs(state, listTokIdx) return True + + +def _detect_task_checkbox(src: str, pos: int, maximum: int) -> bool | None: + """Detect ``[ ]``, ``[x]``, or ``[X]`` at *pos*, followed by whitespace. + + Returns ``True`` (checked), ``False`` (unchecked), or ``None`` (no match). + """ + # Need at least 4 chars: `[`, char, `]`, whitespace + if pos + 4 > maximum: + return None + if src[pos] != "[": + return None + inner = src[pos + 1] + if src[pos + 2] != "]": + return None + if inner == " ": + checked = False + elif inner in ("x", "X"): + checked = True + else: + return None + # After `]`, must have whitespace + if src[pos + 3] not in (" ", "\t"): + return None + return checked diff --git a/markdown_it/rules_inline/strikethrough.py b/markdown_it/rules_inline/strikethrough.py index ec816281..e6bf488a 100644 --- a/markdown_it/rules_inline/strikethrough.py +++ b/markdown_it/rules_inline/strikethrough.py @@ -1,11 +1,16 @@ -# ~~strike through~~ +# ~~strike through~~ (and optionally ~single tilde~) from __future__ import annotations from .state_inline import Delimiter, StateInline def tokenize(state: StateInline, silent: bool) -> bool: - """Insert each marker as a separate text token, and add it to delimiter list""" + """Insert each marker as a separate text token, and add it to delimiter list. + + When the ``strikethrough_single_tilde`` option is enabled on the + ``MarkdownIt`` instance, single ``~`` delimiters are also accepted and + runs of three or more tildes are rejected (matching GitHub's rendering behaviour). + """ start = state.pos ch = state.src[start] @@ -18,30 +23,58 @@ def tokenize(state: StateInline, silent: bool) -> bool: scanned = state.scanDelims(state.pos, True) length = scanned.length - if length < 2: - return False + single_tilde = state.md.options.get("strikethrough_single_tilde", False) - if length % 2: - token = state.push("text", "", 0) - token.content = ch - length -= 1 + if single_tilde: + # GitHub mode: only accept exactly 1 or 2 tildes. + if length < 1: + return False + if length > 2: + # Consume 3+ tildes as plain text so the parser doesn't + # re-enter and match a subset of them. + token = state.push("text", "", 0) + token.content = ch * length + state.pos += scanned.length + return True - i = 0 - while i < length: token = state.push("text", "", 0) - token.content = ch + ch + token.content = ch * length state.delimiters.append( Delimiter( marker=ord(ch), - length=0, # disable "rule of 3" length checks meant for emphasis + length=0, # disable "rule of 3" length checks token=len(state.tokens) - 1, end=-1, open=scanned.can_open, close=scanned.can_close, ) ) + else: + # Original markdown-it behaviour: minimum 2, split odd runs. + if length < 2: + return False + + if length % 2: + token = state.push("text", "", 0) + token.content = ch + length -= 1 + + i = 0 + while i < length: + token = state.push("text", "", 0) + token.content = ch + ch + state.delimiters.append( + Delimiter( + marker=ord(ch), + length=0, # disable "rule of 3" length checks + token=len(state.tokens) - 1, + end=-1, + open=scanned.can_open, + close=scanned.can_close, + ) + ) - i += 2 + i += 2 state.pos += scanned.length @@ -51,6 +84,7 @@ def tokenize(state: StateInline, silent: bool) -> bool: def _postProcess(state: StateInline, delimiters: list[Delimiter]) -> None: loneMarkers = [] maximum = len(delimiters) + single_tilde = state.md.options.get("strikethrough_single_tilde", False) i = 0 while i < maximum: @@ -66,18 +100,29 @@ def _postProcess(state: StateInline, delimiters: list[Delimiter]) -> None: endDelim = delimiters[startDelim.end] + # In single-tilde mode, opener and closer must have the same width + # (both `~` or both `~~`). The width is stored in the text token. + if single_tilde: + opener_content = state.tokens[startDelim.token].content + closer_content = state.tokens[endDelim.token].content + if opener_content != closer_content: + i += 1 + continue + + markup = state.tokens[startDelim.token].content + token = state.tokens[startDelim.token] token.type = "s_open" token.tag = "s" token.nesting = 1 - token.markup = "~~" + token.markup = markup token.content = "" token = state.tokens[endDelim.token] token.type = "s_close" token.tag = "s" token.nesting = -1 - token.markup = "~~" + token.markup = markup token.content = "" if ( diff --git a/markdown_it/utils.py b/markdown_it/utils.py index 2571a158..480ca868 100644 --- a/markdown_it/utils.py +++ b/markdown_it/utils.py @@ -41,6 +41,12 @@ class OptionsType(TypedDict): This is a Python only option, and is intended for the use of round-trip parsing. """ + tasklists: NotRequired[bool] + """Enable GFM task list checkbox detection in list items.""" + alerts: NotRequired[bool] + """Enable GitHub-style alert detection in blockquotes.""" + strikethrough_single_tilde: NotRequired[bool] + """Allow single tilde ``~text~`` for strikethrough in addition to double.""" class PresetType(TypedDict): diff --git a/pyproject.toml b/pyproject.toml index 2414f41d..34d54fce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ testing = [ "pytest", "pytest-cov", "pytest-regressions", + "pytest-timeout", "requests", ] benchmarking = [ @@ -114,3 +115,4 @@ ignore_missing_imports = true [tool.pytest.ini_options] xfail_strict = true +timeout = 10 diff --git a/tests/test_port/fixtures/alerts.md b/tests/test_port/fixtures/alerts.md new file mode 100644 index 00000000..4a4b0833 --- /dev/null +++ b/tests/test_port/fixtures/alerts.md @@ -0,0 +1,172 @@ +Basic note alert +. +> [!NOTE] +> This is a note. +. +
+

Note

+

This is a note.

+
+. + +Basic tip alert +. +> [!TIP] +> This is a tip. +. +
+

Tip

+

This is a tip.

+
+. + +Basic important alert +. +> [!IMPORTANT] +> This is important. +. +
+

Important

+

This is important.

+
+. + +Basic warning alert +. +> [!WARNING] +> This is a warning. +. +
+

Warning

+

This is a warning.

+
+. + +Basic caution alert +. +> [!CAUTION] +> This is a caution. +. +
+

Caution

+

This is a caution.

+
+. + +Case insensitive - lowercase +. +> [!note] +> Lowercase note. +. +
+

Note

+

Lowercase note.

+
+. + +Case insensitive - mixed case +. +> [!Warning] +> Mixed case warning. +. +
+

Warning

+

Mixed case warning.

+
+. + +Multi-paragraph content +. +> [!NOTE] +> First paragraph. +> +> Second paragraph. +. +
+

Note

+

First paragraph.

+

Second paragraph.

+
+. + +Alert with inline formatting +. +> [!TIP] +> This has **bold** and *italic* text. +. +
+

Tip

+

This has bold and italic text.

+
+. + +Not an alert - unknown type +. +> [!UNKNOWN] +> Not an alert. +. +
+

[!UNKNOWN] +Not an alert.

+
+. + +Not an alert - text after marker on same line +. +> [!NOTE] extra text +> Content. +. +
+

[!NOTE] extra text +Content.

+
+. + +Not an alert - not on first line +. +> Some text +> [!NOTE] +> More text. +. +
+

Some text +[!NOTE] +More text.

+
+. + +Regular blockquote unchanged +. +> This is a regular blockquote. +. +
+

This is a regular blockquote.

+
+. + +Empty alert (marker only) +. +> [!NOTE] +. +
+

Note

+
+. + +Alert with list +. +> [!IMPORTANT] +> Things to do: +> +> - item one +> - item two +. +
+

Important

+

Things to do:

+ +
+. diff --git a/tests/test_port/fixtures/strikethrough_single_tilde.md b/tests/test_port/fixtures/strikethrough_single_tilde.md new file mode 100644 index 00000000..bc75d2c5 --- /dev/null +++ b/tests/test_port/fixtures/strikethrough_single_tilde.md @@ -0,0 +1,90 @@ +Single tilde basic +. +~Strikeout~ +. +

Strikeout

+. + +Double tilde still works +. +~~Strikeout~~ +. +

Strikeout

+. + +Single and double don't cross-match +. +~foo~~ +. +

~foo~~

+. + +Single and double don't cross-match (reversed) +. +~~foo~ +. +

~~foo~

+. + +Three or more tildes rejected in inline context +. +a ~~~foo~~~ b +. +

a ~~~foo~~~ b

+. + +Four tildes rejected in inline context +. +a ~~~~foo~~~~ b +. +

a ~~~~foo~~~~ b

+. + +Mixed single and double +. +~single~ and ~~double~~ +. +

single and double

+. + +Nested double inside single +. +~foo ~~bar~~ baz~ +. +

foo bar baz

+. + +Single tilde with spaces (no match - flanking rules) +. +foo ~ bar ~ baz +. +

foo ~ bar ~ baz

+. + +Single tilde with punctuation +. +~foo~bar +. +

foobar

+. + +Multiple single tildes +. +~one~ ~two~ ~three~ +. +

one two three

+. + +Single tilde emphasis priority with bold +. +**~test**~ +. +

~test~

+. + +Single tilde in link context +. +[~link]()~ +. +

~link~

+. diff --git a/tests/test_port/fixtures/tasklists.md b/tests/test_port/fixtures/tasklists.md new file mode 100644 index 00000000..0fd1f126 --- /dev/null +++ b/tests/test_port/fixtures/tasklists.md @@ -0,0 +1,133 @@ +Bullet unchecked and checked +. +- [ ] unchecked item 1 +- [ ] unchecked item 2 +- [x] checked item 3 +. + +. + +Uppercase X +. +- [X] checked uppercase +- [x] checked lowercase +. + +. + +Ordered list with tasks +. +1. [x] checked ordered 1 +2. [ ] unchecked ordered 2 +. +
    +
  1. checked ordered 1
  2. +
  3. unchecked ordered 2
  4. +
+. + +Invalid checkbox syntax +. +- [ ] not a todo +- [ x] not a todo +- [x ] not a todo +. + +. + +Mixed task and non-task items +. +- normal item +- [ ] unchecked +- another normal +- [x] checked +. + +. + +No task items - no class on list +. +- normal 1 +- normal 2 +. + +. + +Nested list with tasks +. +- foo + - [ ] nested unchecked + - [x] nested checked +. + +. + +Task with inline formatting +. +- [x] **bold** task +- [ ] *italic* task +- [x] `code` task +. + +. + +Single task item +. +- [x] only item +. + +. + +Different bullet markers +. ++ [x] plus checked ++ [ ] plus unchecked +. + +. + +Ordered list starting from non-1 +. +3. [x] task three +4. [ ] task four +. +
    +
  1. task three
  2. +
  3. task four
  4. +
+. diff --git a/tests/test_port/test_fixtures.py b/tests/test_port/test_fixtures.py index 74c7ee4d..e741453e 100644 --- a/tests/test_port/test_fixtures.py +++ b/tests/test_port/test_fixtures.py @@ -104,6 +104,16 @@ def test_strikethrough(line, title, input, expected): assert text.rstrip() == expected.rstrip() +@pytest.mark.parametrize( + "line,title,input,expected", + read_fixture_file(FIXTURE_PATH.joinpath("strikethrough_single_tilde.md")), +) +def test_strikethrough_single_tilde(line, title, input, expected): + md = MarkdownIt("gfm-like2") + text = md.render(input) + assert text.rstrip() == expected.rstrip() + + @pytest.mark.parametrize( "line,title,input,expected", read_fixture_file(FIXTURE_PATH.joinpath("disable_code_block.md")), @@ -115,6 +125,26 @@ def test_disable_code_block(line, title, input, expected): assert text.rstrip() == expected.rstrip() +@pytest.mark.parametrize( + "line,title,input,expected", + read_fixture_file(FIXTURE_PATH.joinpath("tasklists.md")), +) +def test_tasklists(line, title, input, expected): + md = MarkdownIt("gfm-like2") + text = md.render(input) + assert text.rstrip() == expected.rstrip() + + +@pytest.mark.parametrize( + "line,title,input,expected", + read_fixture_file(FIXTURE_PATH.joinpath("alerts.md")), +) +def test_alerts(line, title, input, expected): + md = MarkdownIt("gfm-like2") + text = md.render(input) + assert text.rstrip() == expected.rstrip() + + @pytest.mark.parametrize( "line,title,input,expected", read_fixture_file(FIXTURE_PATH.joinpath("issue-fixes.md")), From 883fafa69d73864df33deeca903f0ce57456f1ae Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Sun, 3 May 2026 21:04:55 +0200 Subject: [PATCH 2/4] Address review comments --- markdown_it/renderer.py | 5 ++- markdown_it/rules_block/blockquote.py | 7 ++++- markdown_it/rules_block/list.py | 11 +++---- markdown_it/rules_inline/strikethrough.py | 3 +- markdown_it/utils.py | 2 ++ tests/test_port/fixtures/tasklists.md | 38 +++++++++++------------ 6 files changed, 37 insertions(+), 29 deletions(-) diff --git a/markdown_it/renderer.py b/markdown_it/renderer.py index 094e9f00..4a55b13e 100644 --- a/markdown_it/renderer.py +++ b/markdown_it/renderer.py @@ -220,9 +220,12 @@ def list_item_open( result = self.renderToken(tokens, idx, options, env) if token.meta and "checked" in token.meta: checked_attr = ' checked=""' if token.meta["checked"] else "" + disabled_attr = ( + "" if options.get("tasklists_enabled", False) else ' disabled=""' + ) result += ( ' ' + f'{disabled_attr} type="checkbox"{checked_attr}> ' ) return result diff --git a/markdown_it/rules_block/blockquote.py b/markdown_it/rules_block/blockquote.py index e38bafb2..f79e5fbf 100644 --- a/markdown_it/rules_block/blockquote.py +++ b/markdown_it/rules_block/blockquote.py @@ -274,6 +274,9 @@ def blockquote(state: StateBlock, startLine: int, endLine: int, silent: bool) -> state.blkIndent = 0 # Detect GitHub-style alert marker on the first content line. + # Note: `startLine` here refers to the first content line of the + # blockquote, after the `>` prefix has already been stripped by the + # blockquote parser above (bMarks/tShift adjusted to skip `> `). alert_kind = None if state.md.options.get("alerts", False) and nextLine > startLine: alert_kind = _detect_alert(state, startLine) @@ -296,9 +299,11 @@ def blockquote(state: StateBlock, startLine: int, endLine: int, silent: bool) -> title_token.children = [] token = state.push("alert_title_close", "p", -1) - # Skip the marker line (startLine) and tokenize from startLine + 1 + # Skip the marker line (startLine) and tokenize from startLine + 1. contentStart = startLine + 1 if contentStart < nextLine: + # tokenize() updates state.line to nextLine as part of its + # contract, consistent with the blockquote code path below. state.md.block.tokenize(state, contentStart, nextLine) else: state.line = nextLine diff --git a/markdown_it/rules_block/list.py b/markdown_it/rules_block/list.py index c0eba07a..c8fe7af5 100644 --- a/markdown_it/rules_block/list.py +++ b/markdown_it/rules_block/list.py @@ -241,13 +241,10 @@ def list_block(state: StateBlock, startLine: int, endLine: int, silent: bool) -> checked = _detect_task_checkbox(state.src, contentStart, maximum) if checked is not None: token.meta = {"checked": checked} - # Advance content past the checkbox (3 chars `[x]`) + one whitespace - checkboxLen = 3 - if (contentStart + 3) < maximum and state.src[contentStart + 3] in ( - " ", - "\t", - ): - checkboxLen = 4 + # Advance content past the checkbox: `[x]` (3 chars) + whitespace. + # `_detect_task_checkbox` already guarantees a whitespace char at + # pos+3, so we always consume 4 characters. + checkboxLen = 4 # change current state, then restore it after parser subcall oldTight = state.tight diff --git a/markdown_it/rules_inline/strikethrough.py b/markdown_it/rules_inline/strikethrough.py index e6bf488a..c9875e04 100644 --- a/markdown_it/rules_inline/strikethrough.py +++ b/markdown_it/rules_inline/strikethrough.py @@ -31,7 +31,8 @@ def tokenize(state: StateInline, silent: bool) -> bool: return False if length > 2: # Consume 3+ tildes as plain text so the parser doesn't - # re-enter and match a subset of them. + # re-enter and match a subset of them. This intentionally + # matches GitHub's rendering, where ≥3 tildes are literal text. token = state.push("text", "", 0) token.content = ch * length state.pos += scanned.length diff --git a/markdown_it/utils.py b/markdown_it/utils.py index 480ca868..7287b396 100644 --- a/markdown_it/utils.py +++ b/markdown_it/utils.py @@ -45,6 +45,8 @@ class OptionsType(TypedDict): """Enable GFM task list checkbox detection in list items.""" alerts: NotRequired[bool] """Enable GitHub-style alert detection in blockquotes.""" + tasklists_enabled: NotRequired[bool] + """When True, rendered task list checkboxes are interactive (no disabled attribute).""" strikethrough_single_tilde: NotRequired[bool] """Allow single tilde ``~text~`` for strikethrough in addition to double.""" diff --git a/tests/test_port/fixtures/tasklists.md b/tests/test_port/fixtures/tasklists.md index 0fd1f126..994135d4 100644 --- a/tests/test_port/fixtures/tasklists.md +++ b/tests/test_port/fixtures/tasklists.md @@ -5,9 +5,9 @@ Bullet unchecked and checked - [x] checked item 3 . . @@ -17,8 +17,8 @@ Uppercase X - [x] checked lowercase . . @@ -28,8 +28,8 @@ Ordered list with tasks 2. [ ] unchecked ordered 2 .
    -
  1. checked ordered 1
  2. -
  3. unchecked ordered 2
  4. +
  5. checked ordered 1
  6. +
  7. unchecked ordered 2
. @@ -55,9 +55,9 @@ Mixed task and non-task items . . @@ -81,8 +81,8 @@ Nested list with tasks @@ -95,9 +95,9 @@ Task with inline formatting - [x] `code` task . . @@ -106,7 +106,7 @@ Single task item - [x] only item . . @@ -116,8 +116,8 @@ Different bullet markers + [ ] plus unchecked . . @@ -127,7 +127,7 @@ Ordered list starting from non-1 4. [ ] task four .
    -
  1. task three
  2. -
  3. task four
  4. +
  5. task three
  6. +
  7. task four
. From 094721055e764841f1a627da305b890dd27d0d08 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 4 May 2026 13:07:01 +0200 Subject: [PATCH 3/4] Update documentation --- docs/plugins.md | 5 ++++- docs/using.md | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/plugins.md b/docs/plugins.md index 51a2fa63..98d9600c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -6,6 +6,8 @@ The following plugins are embedded within the core package: - [tables](https://help.github.com/articles/organizing-information-with-tables/) (GFM) - [strikethrough](https://help.github.com/articles/basic-writing-and-formatting-syntax/#styling-text) (GFM) +- [task lists](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-task-lists) (GFM) — `- [x] done` +- [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]` These can be enabled individually: @@ -18,7 +20,8 @@ or as part of a configuration: ```python from markdown_it import MarkdownIt -md = MarkdownIt("gfm-like") +md = MarkdownIt("gfm-like") # tables, strikethrough, linkify +md = MarkdownIt("gfm-like2") # + task lists, alerts, single-tilde strikethrough ``` ```{seealso} diff --git a/docs/using.md b/docs/using.md index e2cf7e7e..507f49c1 100644 --- a/docs/using.md +++ b/docs/using.md @@ -58,6 +58,9 @@ You can define this configuration *via* directly supplying a dictionary or a pre - `gfm-like`: This configures the parser to approximately comply with the [GitHub Flavored Markdown specification](https://github.github.com/gfm/). Compared to `commonmark`, it enables the table, strikethrough and linkify components. **Important**, to use this configuration you must have `linkify-it-py` installed. +- `gfm-like2`: Builds on `gfm-like` and additionally enables task lists (`- [x] done`), + GitHub-style alerts (`> [!NOTE]`), and single-tilde strikethrough (`~text~`). + **Important**, to use this configuration you must have `linkify-it-py` installed. ```{jupyter-execute} from markdown_it.presets import zero From 6167043939a1acf4f64f40154edbf95c4eb9284a Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 6 May 2026 17:31:35 +0200 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20Address=20remaini?= =?UTF-8?q?ng=20gfm-like2=20review=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename `tasklists_enabled` to `tasklists_editable` for clarity and set it explicitly to `False` in the `gfm-like2` preset. Add focused tests for editable task list checkboxes and alert token line maps, and make the alert container map handling explicit in `blockquote.py`. --- markdown_it/presets/__init__.py | 1 + markdown_it/renderer.py | 2 +- markdown_it/rules_block/blockquote.py | 7 +++++-- markdown_it/utils.py | 2 +- tests/test_api/test_main.py | 23 +++++++++++++++++++++++ 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/markdown_it/presets/__init__.py b/markdown_it/presets/__init__.py index 76d38d61..43578148 100644 --- a/markdown_it/presets/__init__.py +++ b/markdown_it/presets/__init__.py @@ -42,6 +42,7 @@ class gfm_like2: # noqa: N801 def make() -> PresetType: config = gfm_like.make() config["options"]["tasklists"] = True + config["options"]["tasklists_editable"] = False config["options"]["alerts"] = True config["options"]["strikethrough_single_tilde"] = True return config diff --git a/markdown_it/renderer.py b/markdown_it/renderer.py index 4a55b13e..f690b091 100644 --- a/markdown_it/renderer.py +++ b/markdown_it/renderer.py @@ -221,7 +221,7 @@ def list_item_open( if token.meta and "checked" in token.meta: checked_attr = ' checked=""' if token.meta["checked"] else "" disabled_attr = ( - "" if options.get("tasklists_enabled", False) else ' disabled=""' + "" if options.get("tasklists_editable", False) else ' disabled=""' ) result += ( ' if state.md.options.get("alerts", False) and nextLine > startLine: alert_kind = _detect_alert(state, startLine) + lines = [startLine, 0] + if alert_kind is not None: # Emit alert tokens instead of blockquote tokens alert_lower = alert_kind.lower() token = state.push("alert_open", "div", 1) token.markup = ">" token.attrSet("class", f"markdown-alert markdown-alert-{alert_lower}") - token.map = lines = [startLine, 0] + token.map = lines token.info = alert_kind token.meta = {"kind": alert_kind} @@ -313,7 +315,7 @@ def blockquote(state: StateBlock, startLine: int, endLine: int, silent: bool) -> else: token = state.push("blockquote_open", "blockquote", 1) token.markup = ">" - token.map = lines = [startLine, 0] + token.map = lines state.md.block.tokenize(state, startLine, nextLine) @@ -322,6 +324,7 @@ def blockquote(state: StateBlock, startLine: int, endLine: int, silent: bool) -> state.lineMax = oldLineMax state.parentType = oldParentType + # Update the opening token map for both alert and blockquote containers. lines[1] = state.line # Restore original tShift; this might not be necessary since the parser diff --git a/markdown_it/utils.py b/markdown_it/utils.py index 7287b396..09e60163 100644 --- a/markdown_it/utils.py +++ b/markdown_it/utils.py @@ -45,7 +45,7 @@ class OptionsType(TypedDict): """Enable GFM task list checkbox detection in list items.""" alerts: NotRequired[bool] """Enable GitHub-style alert detection in blockquotes.""" - tasklists_enabled: NotRequired[bool] + tasklists_editable: NotRequired[bool] """When True, rendered task list checkboxes are interactive (no disabled attribute).""" strikethrough_single_tilde: NotRequired[bool] """Allow single tilde ``~text~`` for strikethrough in addition to double.""" diff --git a/tests/test_api/test_main.py b/tests/test_api/test_main.py index bf789b8a..297c20d8 100644 --- a/tests/test_api/test_main.py +++ b/tests/test_api/test_main.py @@ -92,6 +92,29 @@ def test_override_options(): assert md.options["maxNesting"] == 99 +def test_gfm_like2_tasklists_editable(): + md_default = MarkdownIt("gfm-like2") + assert md_default.options["tasklists_editable"] is False + assert ( + '' + in md_default.render("- [x] done") + ) + + md_editable = MarkdownIt("gfm-like2", {"tasklists_editable": True}) + assert md_editable.options["tasklists_editable"] is True + assert ( + '' + in md_editable.render("- [x] done") + ) + + +def test_gfm_like2_alert_token_map(): + md = MarkdownIt("gfm-like2") + tokens = md.parse("> [!NOTE]\n> body") + assert tokens[0].type == "alert_open" + assert tokens[0].map == [0, 2] + + def test_enable(): md = MarkdownIt("zero").enable("heading") assert md.get_active_rules() == {