markstitch 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- markstitch/__init__.py +153 -0
- markstitch/attributes.py +56 -0
- markstitch/blocks.py +236 -0
- markstitch/core.py +245 -0
- markstitch/extensions.py +323 -0
- markstitch/inline.py +211 -0
- markstitch/parser/__init__.py +2 -0
- markstitch/parser/_directives.py +311 -0
- markstitch/parser/_engine.py +324 -0
- markstitch/parser/_inline.py +327 -0
- markstitch/parser/_rules.py +154 -0
- markstitch/parser/_syntax.py +216 -0
- markstitch/parser/_tables.py +175 -0
- markstitch/parser/parse.py +18 -0
- markstitch/py.typed +0 -0
- markstitch/tables.py +222 -0
- markstitch/templates.py +118 -0
- markstitch-0.1.0.dist-info/METADATA +365 -0
- markstitch-0.1.0.dist-info/RECORD +21 -0
- markstitch-0.1.0.dist-info/WHEEL +4 -0
- markstitch-0.1.0.dist-info/licenses/LICENSE +21 -0
markstitch/__init__.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Compose, parse, and edit YFM documents in Python."""
|
|
2
|
+
|
|
3
|
+
from markstitch.attributes import Attributes
|
|
4
|
+
from markstitch.blocks import (
|
|
5
|
+
BulletList,
|
|
6
|
+
Checklist,
|
|
7
|
+
CodeBlock,
|
|
8
|
+
Comment,
|
|
9
|
+
Cut,
|
|
10
|
+
Header1,
|
|
11
|
+
Header2,
|
|
12
|
+
Header3,
|
|
13
|
+
Header4,
|
|
14
|
+
Header5,
|
|
15
|
+
Header6,
|
|
16
|
+
Heading,
|
|
17
|
+
ListItem,
|
|
18
|
+
MathBlock,
|
|
19
|
+
Note,
|
|
20
|
+
Paragraph,
|
|
21
|
+
Quote,
|
|
22
|
+
Rule,
|
|
23
|
+
Tab,
|
|
24
|
+
Tabs,
|
|
25
|
+
Task,
|
|
26
|
+
)
|
|
27
|
+
from markstitch.core import YFM, Element, Inline, Link, NumberedList, Profile, UnsupportedFeatureError
|
|
28
|
+
from markstitch.extensions import (
|
|
29
|
+
File,
|
|
30
|
+
Footnote,
|
|
31
|
+
FootnoteDefinition,
|
|
32
|
+
Grid,
|
|
33
|
+
Iframe,
|
|
34
|
+
Include,
|
|
35
|
+
Layout,
|
|
36
|
+
LinkDefinition,
|
|
37
|
+
PageTree,
|
|
38
|
+
RawHTML,
|
|
39
|
+
ReferenceImage,
|
|
40
|
+
ReferenceLink,
|
|
41
|
+
StyledBlock,
|
|
42
|
+
Term,
|
|
43
|
+
TermDefinition,
|
|
44
|
+
Toc,
|
|
45
|
+
Video,
|
|
46
|
+
Visibility,
|
|
47
|
+
WikiInclude,
|
|
48
|
+
)
|
|
49
|
+
from markstitch.inline import (
|
|
50
|
+
Bold,
|
|
51
|
+
Code,
|
|
52
|
+
Color,
|
|
53
|
+
Emoji,
|
|
54
|
+
Highlight,
|
|
55
|
+
Image,
|
|
56
|
+
Italic,
|
|
57
|
+
LineBreak,
|
|
58
|
+
Math,
|
|
59
|
+
Mention,
|
|
60
|
+
Monospace,
|
|
61
|
+
Raw,
|
|
62
|
+
RawInline,
|
|
63
|
+
Strike,
|
|
64
|
+
Subscript,
|
|
65
|
+
Superscript,
|
|
66
|
+
Text,
|
|
67
|
+
Underline,
|
|
68
|
+
)
|
|
69
|
+
from markstitch.parser import ParseError, parse
|
|
70
|
+
from markstitch.tables import Cell, Row, Span, Table, YFMTable
|
|
71
|
+
from markstitch.templates import For, If, InlineFor, InlineIf, Slice, Variable
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"YFM",
|
|
75
|
+
"Attributes",
|
|
76
|
+
"Bold",
|
|
77
|
+
"BulletList",
|
|
78
|
+
"Cell",
|
|
79
|
+
"Checklist",
|
|
80
|
+
"Code",
|
|
81
|
+
"CodeBlock",
|
|
82
|
+
"Color",
|
|
83
|
+
"Comment",
|
|
84
|
+
"Cut",
|
|
85
|
+
"Element",
|
|
86
|
+
"Emoji",
|
|
87
|
+
"File",
|
|
88
|
+
"Footnote",
|
|
89
|
+
"FootnoteDefinition",
|
|
90
|
+
"For",
|
|
91
|
+
"Grid",
|
|
92
|
+
"Header1",
|
|
93
|
+
"Header2",
|
|
94
|
+
"Header3",
|
|
95
|
+
"Header4",
|
|
96
|
+
"Header5",
|
|
97
|
+
"Header6",
|
|
98
|
+
"Heading",
|
|
99
|
+
"Highlight",
|
|
100
|
+
"If",
|
|
101
|
+
"Iframe",
|
|
102
|
+
"Image",
|
|
103
|
+
"Include",
|
|
104
|
+
"Inline",
|
|
105
|
+
"InlineFor",
|
|
106
|
+
"InlineIf",
|
|
107
|
+
"Italic",
|
|
108
|
+
"Layout",
|
|
109
|
+
"LineBreak",
|
|
110
|
+
"Link",
|
|
111
|
+
"LinkDefinition",
|
|
112
|
+
"ListItem",
|
|
113
|
+
"Math",
|
|
114
|
+
"MathBlock",
|
|
115
|
+
"Mention",
|
|
116
|
+
"Monospace",
|
|
117
|
+
"Note",
|
|
118
|
+
"NumberedList",
|
|
119
|
+
"PageTree",
|
|
120
|
+
"Paragraph",
|
|
121
|
+
"ParseError",
|
|
122
|
+
"Profile",
|
|
123
|
+
"Quote",
|
|
124
|
+
"Raw",
|
|
125
|
+
"RawHTML",
|
|
126
|
+
"RawInline",
|
|
127
|
+
"ReferenceImage",
|
|
128
|
+
"ReferenceLink",
|
|
129
|
+
"Row",
|
|
130
|
+
"Rule",
|
|
131
|
+
"Slice",
|
|
132
|
+
"Span",
|
|
133
|
+
"Strike",
|
|
134
|
+
"StyledBlock",
|
|
135
|
+
"Subscript",
|
|
136
|
+
"Superscript",
|
|
137
|
+
"Tab",
|
|
138
|
+
"Table",
|
|
139
|
+
"Tabs",
|
|
140
|
+
"Task",
|
|
141
|
+
"Term",
|
|
142
|
+
"TermDefinition",
|
|
143
|
+
"Text",
|
|
144
|
+
"Toc",
|
|
145
|
+
"Underline",
|
|
146
|
+
"UnsupportedFeatureError",
|
|
147
|
+
"Variable",
|
|
148
|
+
"Video",
|
|
149
|
+
"Visibility",
|
|
150
|
+
"WikiInclude",
|
|
151
|
+
"YFMTable",
|
|
152
|
+
"parse",
|
|
153
|
+
]
|
markstitch/attributes.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Typed presentation attributes for Diplodoc tables."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from markstitch.core import identifier, one_line
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Attributes:
|
|
12
|
+
"""Presentation chosen by the document author; classes/style must be trusted, not user input."""
|
|
13
|
+
|
|
14
|
+
id: str | None = None
|
|
15
|
+
classes: tuple[str, ...] = ()
|
|
16
|
+
style: Mapping[str, str] = field(default_factory=dict)
|
|
17
|
+
extra: Mapping[str, str] = field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
def to_yfm(self) -> str:
|
|
20
|
+
values: dict[str, str] = {}
|
|
21
|
+
|
|
22
|
+
if self.id is not None:
|
|
23
|
+
values["id"] = identifier(self.id)
|
|
24
|
+
|
|
25
|
+
if self.classes:
|
|
26
|
+
values["class"] = " ".join(identifier(name) for name in self.classes)
|
|
27
|
+
|
|
28
|
+
if self.style:
|
|
29
|
+
values["style"] = "; ".join(self._declaration(name, value) for name, value in self.style.items())
|
|
30
|
+
|
|
31
|
+
for name, value in self.extra.items():
|
|
32
|
+
if not re.fullmatch(r"(?:data-|aria-)[a-z][a-z0-9-]*|title", name):
|
|
33
|
+
raise ValueError("Extra attributes must be data-*, aria-* or title")
|
|
34
|
+
|
|
35
|
+
values[name] = self._value(value)
|
|
36
|
+
|
|
37
|
+
return " ".join(f'{name}="{value}"' for name, value in values.items())
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _value(value: str) -> str:
|
|
41
|
+
one_line(value)
|
|
42
|
+
|
|
43
|
+
if any(character in value for character in "\\\"'{}|<>&"):
|
|
44
|
+
raise ValueError("Attribute value contains a markup delimiter")
|
|
45
|
+
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def _declaration(cls, name: str, value: str) -> str:
|
|
50
|
+
if not re.fullmatch(r"[a-z][a-z-]*|--[a-z][a-z0-9-]*", name):
|
|
51
|
+
raise ValueError("Invalid CSS property name")
|
|
52
|
+
|
|
53
|
+
if not value or not re.fullmatch(r"[\w\s#.,%+/-]+", value) or "/*" in value or "*/" in value:
|
|
54
|
+
raise ValueError("Expected a CSS keyword, color or dimension without functions or delimiters")
|
|
55
|
+
|
|
56
|
+
return f"{name}: {cls._value(value)}"
|
markstitch/blocks.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Block-level YFM constructs."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from markstitch.core import (
|
|
6
|
+
MAX_HEADING_LEVEL,
|
|
7
|
+
Container,
|
|
8
|
+
Element,
|
|
9
|
+
Profile,
|
|
10
|
+
blocks,
|
|
11
|
+
choice,
|
|
12
|
+
directive,
|
|
13
|
+
escape,
|
|
14
|
+
fence,
|
|
15
|
+
identifier,
|
|
16
|
+
indent,
|
|
17
|
+
inline,
|
|
18
|
+
one_line,
|
|
19
|
+
positive,
|
|
20
|
+
prefix_lines,
|
|
21
|
+
quoted,
|
|
22
|
+
render,
|
|
23
|
+
require,
|
|
24
|
+
)
|
|
25
|
+
from markstitch.inline import render_text
|
|
26
|
+
|
|
27
|
+
CODE_FLAGS = {"showLineNumbers": "line_numbers", "wrap": "wrap"}
|
|
28
|
+
CODE_PROMPT = "prompt"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Paragraph(Container):
|
|
32
|
+
def _render(self, profile: Profile) -> str:
|
|
33
|
+
return render_text(self.children, profile)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Heading(Element):
|
|
38
|
+
_child_fields = ("text",)
|
|
39
|
+
|
|
40
|
+
text: Element | str
|
|
41
|
+
level: int = 1
|
|
42
|
+
anchor: str | None = None
|
|
43
|
+
collapsible: bool = False
|
|
44
|
+
|
|
45
|
+
def _render(self, profile: Profile) -> str:
|
|
46
|
+
if positive(self.level, "level") > MAX_HEADING_LEVEL:
|
|
47
|
+
raise ValueError(f"Heading level must be between 1 and {MAX_HEADING_LEVEL}")
|
|
48
|
+
|
|
49
|
+
if self.collapsible:
|
|
50
|
+
require(profile, "Collapsible heading", Profile.TRACKER, Profile.WIKI)
|
|
51
|
+
|
|
52
|
+
anchor = f" {{#{identifier(self.anchor)}}}" if self.anchor else ""
|
|
53
|
+
collapse = "+" if self.collapsible else ""
|
|
54
|
+
return f"{'#' * self.level}{collapse} {one_line(inline(self.text, profile))}{anchor}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FixedHeading(Heading):
|
|
58
|
+
LEVEL = 1
|
|
59
|
+
|
|
60
|
+
def __init__(self, text: Element | str, *, anchor: str | None = None, collapsible: bool = False) -> None:
|
|
61
|
+
super().__init__(text=text, level=self.LEVEL, anchor=anchor, collapsible=collapsible)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Header1(FixedHeading):
|
|
65
|
+
LEVEL = 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Header2(FixedHeading):
|
|
69
|
+
LEVEL = 2
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Header3(FixedHeading):
|
|
73
|
+
LEVEL = 3
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Header4(FixedHeading):
|
|
77
|
+
LEVEL = 4
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Header5(FixedHeading):
|
|
81
|
+
LEVEL = 5
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Header6(FixedHeading):
|
|
85
|
+
LEVEL = 6
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class BulletList(Container):
|
|
89
|
+
def __init__(self, *children: Element | str, loose: bool = False) -> None:
|
|
90
|
+
super().__init__(*children)
|
|
91
|
+
self.loose = loose
|
|
92
|
+
|
|
93
|
+
def _render(self, profile: Profile) -> str:
|
|
94
|
+
separator = "\n\n" if self.loose else "\n"
|
|
95
|
+
return separator.join(prefix_lines(render(child, profile), "- ") for child in self.children)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ListItem(Container):
|
|
99
|
+
def _render(self, profile: Profile) -> str:
|
|
100
|
+
return blocks(self.children, profile)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Quote(Container):
|
|
104
|
+
def _render(self, profile: Profile) -> str:
|
|
105
|
+
return "\n".join(f"> {line}" if line else ">" for line in blocks(self.children, profile).split("\n"))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class CodeBlock(Element):
|
|
110
|
+
text: str
|
|
111
|
+
language: str = ""
|
|
112
|
+
line_numbers: bool = False
|
|
113
|
+
wrap: bool = False
|
|
114
|
+
prompt: str | None = None
|
|
115
|
+
|
|
116
|
+
def _render(self, profile: Profile) -> str:
|
|
117
|
+
if self.language:
|
|
118
|
+
identifier(self.language)
|
|
119
|
+
|
|
120
|
+
delimiter = fence(self.text)
|
|
121
|
+
flags = "".join(f" {name}" for name, field in CODE_FLAGS.items() if getattr(self, field))
|
|
122
|
+
prompt = ""
|
|
123
|
+
|
|
124
|
+
if self.prompt is not None:
|
|
125
|
+
require(profile, "Code prompt", Profile.DIPLODOC)
|
|
126
|
+
prompt = f" {CODE_PROMPT}={quoted(self.prompt)}"
|
|
127
|
+
|
|
128
|
+
newline = "" if self.text.endswith("\n") else "\n"
|
|
129
|
+
return f"{delimiter}{self.language}{flags}{prompt}\n{self.text}{newline}{delimiter}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class MathBlock(Element):
|
|
134
|
+
text: str
|
|
135
|
+
|
|
136
|
+
def _render(self, profile: Profile) -> str:
|
|
137
|
+
if "$$" in self.text:
|
|
138
|
+
raise ValueError("Math block must not contain a closing delimiter")
|
|
139
|
+
|
|
140
|
+
return f"$$\n{self.text}\n$$"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Rule(Element):
|
|
144
|
+
def _render(self, profile: Profile) -> str:
|
|
145
|
+
return "---"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class Comment(Element):
|
|
150
|
+
text: str
|
|
151
|
+
|
|
152
|
+
def _render(self, profile: Profile) -> str:
|
|
153
|
+
return f"[//]: # ({escape(one_line(self.text))})"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Cut(Container):
|
|
157
|
+
def __init__(self, title: str, *children: Element | str) -> None:
|
|
158
|
+
super().__init__(*children)
|
|
159
|
+
self.title = title
|
|
160
|
+
|
|
161
|
+
def _render(self, profile: Profile) -> str:
|
|
162
|
+
return directive("cut", blocks(self.children, profile), arguments=quoted(self.title))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Note(Container):
|
|
166
|
+
def __init__(self, *children: Element | str, kind: str = "info", title: str | None = None) -> None:
|
|
167
|
+
super().__init__(*children)
|
|
168
|
+
self.kind = kind
|
|
169
|
+
self.title = title
|
|
170
|
+
|
|
171
|
+
def _render(self, profile: Profile) -> str:
|
|
172
|
+
choice(self.kind, {"info", "tip", "warning", "alert"}, "note kind")
|
|
173
|
+
title = f" {quoted(self.title)}" if self.title is not None else ""
|
|
174
|
+
return directive("note", blocks(self.children, profile), arguments=f"{self.kind}{title}")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Tab(Container):
|
|
178
|
+
def __init__(self, title: str, *children: Element | str, selected: bool = False) -> None:
|
|
179
|
+
super().__init__(*children)
|
|
180
|
+
self.title = title
|
|
181
|
+
self.selected = selected
|
|
182
|
+
|
|
183
|
+
def _render(self, profile: Profile) -> str:
|
|
184
|
+
selected = ""
|
|
185
|
+
|
|
186
|
+
if self.selected:
|
|
187
|
+
require(profile, "Selected tab", Profile.DIPLODOC)
|
|
188
|
+
selected = " {selected}"
|
|
189
|
+
|
|
190
|
+
return f"- {escape(one_line(self.title))}{selected}\n\n{indent(blocks(self.children, profile), 2)}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class Tabs(Container):
|
|
194
|
+
def __init__(self, *children: Tab, mode: str = "tabs", group: str | None = None) -> None:
|
|
195
|
+
super().__init__(*children)
|
|
196
|
+
self.mode, self.group = mode, group
|
|
197
|
+
|
|
198
|
+
def _render(self, profile: Profile) -> str:
|
|
199
|
+
if not self.children or any(not isinstance(child, Tab) for child in self.children):
|
|
200
|
+
raise TypeError("Tabs requires one or more Tab elements")
|
|
201
|
+
|
|
202
|
+
choice(self.mode, {"tabs", "radio", "dropdown", "accordion"}, "tabs mode")
|
|
203
|
+
mode = ""
|
|
204
|
+
group = ""
|
|
205
|
+
|
|
206
|
+
if self.mode != "tabs":
|
|
207
|
+
require(profile, "Interactive tabs mode", Profile.DIPLODOC)
|
|
208
|
+
mode = f" {self.mode}"
|
|
209
|
+
|
|
210
|
+
if self.group is not None:
|
|
211
|
+
require(profile, "Tabs synchronization", Profile.DIPLODOC)
|
|
212
|
+
group = f" group={identifier(self.group)}"
|
|
213
|
+
|
|
214
|
+
return directive("list", blocks(self.children, profile), arguments=f"tabs{mode}{group}", end="endlist")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class Task(Element):
|
|
219
|
+
_child_fields = ("text",)
|
|
220
|
+
|
|
221
|
+
text: Element | str
|
|
222
|
+
checked: bool = False
|
|
223
|
+
|
|
224
|
+
def _render(self, profile: Profile) -> str:
|
|
225
|
+
prefix = "- " if profile == Profile.DIPLODOC else ""
|
|
226
|
+
check = "x" if self.checked else " "
|
|
227
|
+
return f"{prefix}[{check}] {one_line(inline(self.text, profile))}"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class Checklist(Container):
|
|
231
|
+
def _render(self, profile: Profile) -> str:
|
|
232
|
+
if any(not isinstance(child, Task) for child in self.children):
|
|
233
|
+
raise TypeError("Checklist requires Task elements")
|
|
234
|
+
|
|
235
|
+
separator = "\n" if profile == Profile.DIPLODOC else "\n\n"
|
|
236
|
+
return separator.join(render(child, profile) for child in self.children)
|
markstitch/core.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Document nodes and rendering contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Collection, Iterator
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from html import escape as html_escape
|
|
9
|
+
from typing import ClassVar
|
|
10
|
+
from urllib.parse import quote, urlsplit
|
|
11
|
+
|
|
12
|
+
MAX_HEADING_LEVEL = 6
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Profile(StrEnum):
|
|
16
|
+
TRACKER = "tracker"
|
|
17
|
+
WIKI = "wiki"
|
|
18
|
+
DIPLODOC = "diplodoc"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UnsupportedFeatureError(ValueError):
|
|
22
|
+
"""The selected target does not document support for this feature."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Element:
|
|
26
|
+
_child_fields: ClassVar[tuple[str, ...]] = ()
|
|
27
|
+
|
|
28
|
+
def __iter__(self) -> Iterator[Element]:
|
|
29
|
+
return self.iter_children()
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
return self.to_yfm()
|
|
33
|
+
|
|
34
|
+
def to_yfm(self, *, profile: Profile | str = Profile.TRACKER) -> str:
|
|
35
|
+
return self._render(Profile(profile))
|
|
36
|
+
|
|
37
|
+
def iter_children(self) -> Iterator[Element]:
|
|
38
|
+
"""Yield direct element children; scalar text and metadata remain on their owner."""
|
|
39
|
+
pending = [getattr(self, name) for name in reversed(self._child_fields)]
|
|
40
|
+
|
|
41
|
+
while pending:
|
|
42
|
+
child = pending.pop()
|
|
43
|
+
|
|
44
|
+
if isinstance(child, Element):
|
|
45
|
+
yield child
|
|
46
|
+
elif isinstance(child, (tuple, list)):
|
|
47
|
+
pending.extend(reversed(child))
|
|
48
|
+
|
|
49
|
+
def walk(self) -> Iterator[Element]:
|
|
50
|
+
"""Depth-first preorder, including self. Shared nodes are visited per occurrence."""
|
|
51
|
+
pending: list[tuple[Element, bool]] = [(self, False)]
|
|
52
|
+
ancestors: set[int] = set()
|
|
53
|
+
|
|
54
|
+
while pending:
|
|
55
|
+
node, leaving = pending.pop()
|
|
56
|
+
|
|
57
|
+
if leaving:
|
|
58
|
+
ancestors.remove(id(node))
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
if id(node) in ancestors:
|
|
62
|
+
raise ValueError("Cycle in document tree")
|
|
63
|
+
|
|
64
|
+
ancestors.add(id(node))
|
|
65
|
+
yield node
|
|
66
|
+
pending.append((node, True))
|
|
67
|
+
pending.extend((child, False) for child in reversed(tuple(node)))
|
|
68
|
+
|
|
69
|
+
def _render(self, profile: Profile) -> str:
|
|
70
|
+
raise NotImplementedError(f"{type(self).__name__} must implement _render")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Inline(Element):
|
|
74
|
+
"""An element that can appear inside a paragraph or a link label."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Container(Element):
|
|
78
|
+
_child_fields: ClassVar[tuple[str, ...]] = ("children",)
|
|
79
|
+
|
|
80
|
+
def __init__(self, *children: Element | str) -> None:
|
|
81
|
+
self.children = tuple(children)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class YFM(Container):
|
|
85
|
+
def __init__(self, *children: Element | str, profile: Profile | str = Profile.TRACKER) -> None:
|
|
86
|
+
super().__init__(*children)
|
|
87
|
+
self.profile = Profile(profile)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_yfm(cls, source: str, *, profile: Profile | str = Profile.TRACKER, strict: bool = False) -> YFM:
|
|
91
|
+
# Lazy import breaks the parser <-> document node dependency.
|
|
92
|
+
from markstitch.parser import parse # noqa: PLC0415 — parser imports the document model.
|
|
93
|
+
|
|
94
|
+
return parse(source, profile=profile, strict=strict)
|
|
95
|
+
|
|
96
|
+
def to_yfm(self, *, profile: Profile | str | None = None) -> str:
|
|
97
|
+
return self._render(self.profile if profile is None else Profile(profile))
|
|
98
|
+
|
|
99
|
+
def _render(self, profile: Profile) -> str:
|
|
100
|
+
return blocks(self.children, profile)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class NumberedList(Container):
|
|
104
|
+
def __init__(self, *children: Element | str, start: int = 1, loose: bool = False) -> None:
|
|
105
|
+
super().__init__(*children)
|
|
106
|
+
self.start = start
|
|
107
|
+
self.loose = loose
|
|
108
|
+
|
|
109
|
+
def _render(self, profile: Profile) -> str:
|
|
110
|
+
positive(self.start, "start")
|
|
111
|
+
separator = "\n\n" if self.loose else "\n"
|
|
112
|
+
return separator.join(
|
|
113
|
+
prefix_lines(render(child, profile), f"{number}. ")
|
|
114
|
+
for number, child in enumerate(self.children, start=self.start)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Link(Inline):
|
|
119
|
+
_child_fields = ("text",)
|
|
120
|
+
|
|
121
|
+
def __init__(self, text: Inline | str, href: str, *, title: str | None = None, target: str | None = None) -> None:
|
|
122
|
+
self.text = text
|
|
123
|
+
self.href = href
|
|
124
|
+
self.title = title
|
|
125
|
+
self.target = target
|
|
126
|
+
|
|
127
|
+
def _render(self, profile: Profile) -> str:
|
|
128
|
+
title = f" {quoted(self.title)}" if self.title is not None else ""
|
|
129
|
+
target = ""
|
|
130
|
+
|
|
131
|
+
if self.target is not None:
|
|
132
|
+
require(profile, "Link target", Profile.DIPLODOC)
|
|
133
|
+
choice(self.target, {"_blank", "_self"}, "target")
|
|
134
|
+
target = f"{{target={self.target}}}"
|
|
135
|
+
|
|
136
|
+
return f"[{inline(self.text, profile)}]({destination(self.href)}{title}){target}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def render(value: Element | str, profile: Profile) -> str:
|
|
140
|
+
if isinstance(value, Element):
|
|
141
|
+
return value._render(profile)
|
|
142
|
+
|
|
143
|
+
if isinstance(value, str):
|
|
144
|
+
return escape(value)
|
|
145
|
+
|
|
146
|
+
raise TypeError(f"Expected an Element or str, got {type(value).__name__}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def blocks(values: tuple[Element | str, ...], profile: Profile) -> str:
|
|
150
|
+
return "\n\n".join(render(value, profile) for value in values)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def inline(value: Element | str, profile: Profile) -> str:
|
|
154
|
+
if not isinstance(value, (Inline, str)):
|
|
155
|
+
raise TypeError("Block content cannot be used inline")
|
|
156
|
+
|
|
157
|
+
return render(value, profile)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def require(profile: Profile, feature: str, *supported: Profile) -> None:
|
|
161
|
+
if profile not in supported:
|
|
162
|
+
raise UnsupportedFeatureError(f"{feature} is not supported by the {profile.value} profile")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def one_line(text: str) -> str:
|
|
166
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in text):
|
|
167
|
+
raise ValueError("Expected single-line text without control characters")
|
|
168
|
+
|
|
169
|
+
return text
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def identifier(text: str) -> str:
|
|
173
|
+
if not re.fullmatch(r"[\w.-]+", text):
|
|
174
|
+
raise ValueError(f"Invalid identifier: {text!r}")
|
|
175
|
+
|
|
176
|
+
return text
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def reference_label(text: str) -> str:
|
|
180
|
+
if not text.strip() or len(text) > 999:
|
|
181
|
+
raise ValueError("Reference label must contain 1 to 999 characters")
|
|
182
|
+
|
|
183
|
+
normalized = one_line(" ".join(text.split()))
|
|
184
|
+
|
|
185
|
+
if not re.fullmatch(r"(?:\\.|[^\[\]\\])+", normalized):
|
|
186
|
+
raise ValueError("Reference label must escape brackets and backslashes")
|
|
187
|
+
|
|
188
|
+
return normalized
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def positive(value: int, name: str) -> int:
|
|
192
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
193
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
194
|
+
|
|
195
|
+
return value
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def choice(value: str, options: Collection[str], name: str) -> str:
|
|
199
|
+
if value not in options:
|
|
200
|
+
raise ValueError(f"Invalid {name}: {value!r}; expected one of {sorted(options)}")
|
|
201
|
+
|
|
202
|
+
return value
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def quoted(text: str) -> str:
|
|
206
|
+
value = html_escape(one_line(text), quote=True).replace("\\", "\").replace("%", "%").replace("{", "{")
|
|
207
|
+
return f'"{value}"'
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def directive(name: str, body: str, *, arguments: str = "", end: str | None = None) -> str:
|
|
211
|
+
suffix = f" {arguments}" if arguments else ""
|
|
212
|
+
return f"{{% {name}{suffix} %}}\n\n{body}\n\n{{% {end or f'end{name}'} %}}"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def indent(text: str, width: int) -> str:
|
|
216
|
+
return "\n".join(f"{' ' * width}{line}" if line else "" for line in text.split("\n"))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def prefix_lines(text: str, prefix: str) -> str:
|
|
220
|
+
first, separator, rest = text.partition("\n")
|
|
221
|
+
return f"{prefix}{first}" + (f"\n{indent(rest, len(prefix))}" if separator else "")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def fence(text: str, minimum: int = 3) -> str:
|
|
225
|
+
return "`" * max(minimum, max((len(run) + 1 for run in re.findall(r"`+", text)), default=0))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def escape(text: str) -> str:
|
|
229
|
+
special = frozenset("\\`*_[](){}#|~^+=<>!$@:")
|
|
230
|
+
escaped = "".join(
|
|
231
|
+
f"\\{character}" if character in special else character for character in text.replace("&", "&")
|
|
232
|
+
)
|
|
233
|
+
escaped = re.sub(r"(?m)^([ \t]*\d+)\.", r"\1\\.", escaped)
|
|
234
|
+
return re.sub(r"(?m)^([ \t]*)-", r"\1\\-", escaped)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def destination(href: str) -> str:
|
|
238
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in href):
|
|
239
|
+
raise ValueError("URL must not contain control characters")
|
|
240
|
+
|
|
241
|
+
if urlsplit(href).scheme.lower() not in {"", "http", "https", "mailto"}:
|
|
242
|
+
raise ValueError("Unsupported URL scheme")
|
|
243
|
+
|
|
244
|
+
encoded = quote(href, safe="/:?#@!$&'*+,;=%~._-")
|
|
245
|
+
return re.sub(r"&(?=(?:#\d+|#[xX][0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]+);)", "%26", encoded)
|