Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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}
Expand Down
3 changes: 3 additions & 0 deletions docs/using.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions markdown_it/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"zero": presets.zero.make(),
"commonmark": presets.commonmark.make(),
"gfm-like": presets.gfm_like.make(),
"gfm-like2": presets.gfm_like2.make(),
}


Expand Down
22 changes: 21 additions & 1 deletion markdown_it/presets/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
20 changes: 20 additions & 0 deletions markdown_it/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 += (
'<input class="task-list-item-checkbox"'
f'{disabled_attr} type="checkbox"{checked_attr}> '
)
return result

def code_inline(
self, tokens: Sequence[Token], idx: int, options: OptionsDict, env: EnvType
) -> str:
Expand Down
81 changes: 75 additions & 6 deletions markdown_it/rules_block/blockquote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <p class="markdown-alert-title">Kind</p>
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
Expand All @@ -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
63 changes: 63 additions & 0 deletions markdown_it/rules_block/list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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"):
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Loading
Loading