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 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
+ ]
@@ -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("\\", "&#92;").replace("%", "&#37;").replace("{", "&#123;")
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("&", "&amp;")
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)