Skip to content

Commit 909c739

Browse files
authored
✨ NEW: Add field list plugin (#33)
Based on the restructured text syntax: https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#field-lists
1 parent 65f73e0 commit 909c739

File tree

5 files changed

+808
-0
lines changed

5 files changed

+808
-0
lines changed

docs/index.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ html_string = md.render("some *Markdown*")
6161
.. autofunction:: mdit_py_plugins.tasklists.tasklists_plugin
6262
```
6363

64+
## Field Lists
65+
66+
```{eval-rst}
67+
.. autofunction:: mdit_py_plugins.field_list.fieldlist_plugin
68+
```
69+
6470
## Heading Anchors
6571

6672
```{eval-rst}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""Field list plugin"""
2+
from contextlib import contextmanager
3+
from typing import Tuple
4+
5+
from markdown_it import MarkdownIt
6+
from markdown_it.rules_block import StateBlock
7+
8+
9+
def fieldlist_plugin(md: MarkdownIt):
10+
"""Field lists are mappings from field names to field bodies, based on the
11+
`reStructureText syntax
12+
<https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#field-lists>`_.
13+
14+
.. code-block:: md
15+
16+
:name *markup*:
17+
:name1: body content
18+
:name2: paragraph 1
19+
20+
paragraph 2
21+
:name3:
22+
paragraph 1
23+
24+
paragraph 2
25+
26+
A field name may consist of any characters except colons (":").
27+
Inline markup is parsed in field names.
28+
29+
The field name is followed by whitespace and the field body.
30+
The field body may be empty or contain multiple body elements.
31+
The field body is aligned either by the start of the body on the first line or,
32+
if no body content is on the first line, by 2 spaces.
33+
"""
34+
md.block.ruler.before(
35+
"paragraph",
36+
"fieldlist",
37+
_fieldlist_rule,
38+
{"alt": ["paragraph", "reference", "blockquote"]},
39+
)
40+
41+
42+
def parseNameMarker(state: StateBlock, startLine: int) -> Tuple[int, str]:
43+
"""Parse field name: `:name:`
44+
45+
:returns: position after name marker, name text
46+
"""
47+
start = state.bMarks[startLine] + state.tShift[startLine]
48+
pos = start
49+
maximum = state.eMarks[startLine]
50+
51+
# marker should have at least 3 chars (colon + character + colon)
52+
if pos + 2 >= maximum:
53+
return -1, ""
54+
55+
# first character should be ':'
56+
if state.src[pos] != ":":
57+
return -1, ""
58+
59+
# scan name length
60+
name_length = 1
61+
found_close = False
62+
for ch in state.src[pos + 1 :]:
63+
if ch == "\n":
64+
break
65+
if ch == ":":
66+
# TODO backslash escapes
67+
found_close = True
68+
break
69+
name_length += 1
70+
71+
if not found_close:
72+
return -1, ""
73+
74+
# get name
75+
name_text = state.src[pos + 1 : pos + name_length]
76+
77+
# name should contain at least one character
78+
if not name_text.strip():
79+
return -1, ""
80+
81+
return pos + name_length + 1, name_text
82+
83+
84+
@contextmanager
85+
def set_parent_type(state: StateBlock, name: str):
86+
"""Temporarily set parent type to `name`"""
87+
oldParentType = state.parentType
88+
state.parentType = name
89+
yield
90+
state.parentType = oldParentType
91+
92+
93+
def _fieldlist_rule(state: StateBlock, startLine: int, endLine: int, silent: bool):
94+
# adapted from markdown_it/rules_block/list.py::list_block
95+
96+
# if it's indented more than 3 spaces, it should be a code block
97+
if state.sCount[startLine] - state.blkIndent >= 4:
98+
return False
99+
100+
posAfterName, name_text = parseNameMarker(state, startLine)
101+
if posAfterName < 0:
102+
return False
103+
104+
# For validation mode we can terminate immediately
105+
if silent:
106+
return True
107+
108+
# start field list
109+
token = state.push("field_list_open", "dl", 1)
110+
token.attrSet("class", "field-list")
111+
token.map = listLines = [startLine, 0]
112+
113+
# iterate list items
114+
nextLine = startLine
115+
116+
with set_parent_type(state, "fieldlist"):
117+
118+
while nextLine < endLine:
119+
120+
# create name tokens
121+
token = state.push("fieldlist_name_open", "dt", 1)
122+
token.map = [startLine, startLine]
123+
token = state.push("inline", "", 0)
124+
token.map = [startLine, startLine]
125+
token.content = name_text
126+
token.children = []
127+
token = state.push("fieldlist_name_close", "dt", -1)
128+
129+
# set indent positions
130+
pos = posAfterName
131+
maximum = state.eMarks[nextLine]
132+
offset = (
133+
state.sCount[nextLine]
134+
+ posAfterName
135+
- (state.bMarks[startLine] + state.tShift[startLine])
136+
)
137+
138+
# find indent to start of body on first line
139+
while pos < maximum:
140+
ch = state.srcCharCode[pos]
141+
142+
if ch == 0x09: # \t
143+
offset += 4 - (offset + state.bsCount[nextLine]) % 4
144+
elif ch == 0x20: # \s
145+
offset += 1
146+
else:
147+
break
148+
149+
pos += 1
150+
151+
contentStart = pos
152+
153+
# set indent for body text
154+
if contentStart >= maximum:
155+
# no body on first line, so use constant indentation
156+
# TODO adapt to indentation of subsequent lines?
157+
indent = 2
158+
else:
159+
indent = offset
160+
161+
# Run subparser on the field body
162+
token = state.push("fieldlist_body_open", "dd", 1)
163+
token.map = itemLines = [startLine, 0]
164+
165+
# change current state, then restore it after parser subcall
166+
oldTShift = state.tShift[startLine]
167+
oldSCount = state.sCount[startLine]
168+
oldBlkIndent = state.blkIndent
169+
170+
state.tShift[startLine] = contentStart - state.bMarks[startLine]
171+
state.sCount[startLine] = offset
172+
state.blkIndent = indent
173+
174+
state.md.block.tokenize(state, startLine, endLine)
175+
176+
state.blkIndent = oldBlkIndent
177+
state.tShift[startLine] = oldTShift
178+
state.sCount[startLine] = oldSCount
179+
180+
token = state.push("fieldlist_body_close", "dd", -1)
181+
182+
nextLine = startLine = state.line
183+
itemLines[1] = nextLine
184+
185+
if nextLine >= endLine:
186+
break
187+
188+
contentStart = state.bMarks[startLine]
189+
190+
# Try to check if list is terminated or continued.
191+
if state.sCount[nextLine] < state.blkIndent:
192+
break
193+
194+
# if it's indented more than 3 spaces, it should be a code block
195+
if state.sCount[startLine] - state.blkIndent >= 4:
196+
break
197+
198+
# get next field item
199+
posAfterName, name_text = parseNameMarker(state, startLine)
200+
if posAfterName < 0:
201+
break
202+
203+
# Finalize list
204+
token = state.push("field_list_close", "dl", -1)
205+
listLines[1] = nextLine
206+
state.line = nextLine
207+
208+
return True

0 commit comments

Comments
 (0)