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 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..43578148 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,23 @@ 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"]["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 6d60589a..f690b091 100644 --- a/markdown_it/renderer.py +++ b/markdown_it/renderer.py @@ -209,6 +209,26 @@ 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 "" + disabled_attr = ( + "" if options.get("tasklists_editable", False) else ' disabled=""' + ) + 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..de2d4f2d 100644 --- a/markdown_it/rules_block/blockquote.py +++ b/markdown_it/rules_block/blockquote.py @@ -273,17 +273,58 @@ 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. + # 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) + + 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 + 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: + # 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 + + token = state.push("alert_close", "div", -1) + token.markup = ">" + else: + token = state.push("blockquote_open", "blockquote", 1) + token.markup = ">" + token.map = lines - 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 + # 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 @@ -297,3 +338,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..c8fe7af5 100644 --- a/markdown_it/rules_block/list.py +++ b/markdown_it/rules_block/list.py @@ -235,8 +235,20 @@ 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: `[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 + oldBMark = state.bMarks[startLine] oldTShift = state.tShift[startLine] oldSCount = state.sCount[startLine] @@ -252,6 +264,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 +295,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 +346,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 +381,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..c9875e04 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,59 @@ def tokenize(state: StateInline, silent: bool) -> bool: scanned = state.scanDelims(state.pos, True) length = scanned.length - if length < 2: - return False - - if length % 2: - token = state.push("text", "", 0) - token.content = ch - length -= 1 + single_tilde = state.md.options.get("strikethrough_single_tilde", False) + + 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. 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 + 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 +85,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 +101,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..09e60163 100644 --- a/markdown_it/utils.py +++ b/markdown_it/utils.py @@ -41,6 +41,14 @@ 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.""" + 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.""" 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_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() == { 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..994135d4 --- /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")),