Skip to content

Commit 003bb84

Browse files
und1sk0claude
andcommitted
feat: add text color and highlight marks
Adds textStyle and highlight ProseMirror marks (node builders in substack/nodes.py), with Markdown round-trip via Pandoc-style span attributes: [text]{style="color:#hex"} and [text]{style="background-color:#hex"}, combinable in one span. Verified against Substack's real editor schema by applying both marks to a live draft and reading the rendered HTML back: textStyle becomes a <span data-color> with inline color, highlight becomes a <mark data-color> with inline background-color. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent d3b5957 commit 003bb84

8 files changed

Lines changed: 202 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Added
6+
7+
- Text color and highlight marks (`textStyle` / `highlight`), settable via
8+
`substack.nodes.text_style_mark()` / `highlight_mark()` for direct node
9+
construction, and in Markdown via Pandoc-style span attributes
10+
(`[text]{style="color:#hex"}` / `{style="background-color:#hex"}`), parsed
11+
and exported round-trip. Verified against Substack's real editor schema
12+
(confirmed by applying both to a live draft and reading the rendered HTML
13+
back: `textStyle` becomes a `<span data-color>` with inline `color`,
14+
`highlight` becomes a `<mark data-color>` with inline `background-color`).
15+
316
## 0.10.0
417

518
### Added

‎README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,10 @@ For direct ProseMirror node construction, see the
201201
## Markdown
202202

203203
Supported Markdown includes headings, paragraphs, bold, italic, inline code,
204-
strikethrough, superscript, subscript, links, images, linked images, image
205-
captions, code blocks, blockquotes, ordered and unordered lists, horizontal
206-
rules, footnotes, LaTeX math, pull quotes, and callouts.
204+
strikethrough, superscript, subscript, text color, highlight, links, images,
205+
linked images, image captions, code blocks, blockquotes, ordered and
206+
unordered lists, horizontal rules, footnotes, LaTeX math, pull quotes, and
207+
callouts.
207208

208209
```python
209210
from substack.post import Post

‎docs/markdown.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,26 @@ Pass `api=` when your Markdown references local images so they can be uploaded
3535
| `~subscript~` | subscript |
3636
| `[text](https://example.com)` | link |
3737
| `<https://example.com>` | autolinked URL |
38+
| `[text]{style="color:#hex"}` | text color |
39+
| `[text]{style="background-color:#hex"}` | highlight |
3840

3941
Note that subscript uses a single tilde (`~x~`) and strikethrough uses a double
4042
tilde (`~~x~~`); both work in the same document.
4143

44+
Text color and highlight use Pandoc-style span attributes: a `[span]` followed
45+
immediately by `{style="..."}`. Only the `color` and `background-color`
46+
declarations are recognized (others are ignored), and both may be combined in
47+
one span:
48+
49+
```markdown
50+
[colorized text]{style="color:#ff00ff"}
51+
[highlighted text]{style="background-color:#ffff00"}
52+
[both]{style="color:#ffffff; background-color:#000000"}
53+
```
54+
55+
A `{...}` block elsewhere (after an image, link, or inline code) is left as
56+
plain text; the syntax is only recognized immediately after a `[span]`.
57+
4258
## Headings
4359

4460
Levels 1–6, using `#` through `######`. Headings may contain inline formatting

‎substack/mdexport.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
"superscript",
2626
"subscript",
2727
"link",
28+
"textStyle",
29+
"highlight",
2830
}
2931
_IMAGE_ATTRS = {
3032
"src",
@@ -117,14 +119,18 @@ def _code_span(value):
117119
return f"{delimiter}{padding}{value}{padding}{delimiter}"
118120

119121

122+
_ATTR_MARKS = {"link", "textStyle", "highlight"}
123+
_STYLE_PROPERTY = {"textStyle": "color", "highlight": "background-color"}
124+
125+
120126
def _render_marked_text(value, marks, node, unsupported):
121127
if not isinstance(value, str) or not isinstance(marks, list):
122128
raise ValueError("Malformed draft body: text and marks have invalid types")
123129
for mark in marks:
124130
_require_dict(mark, "mark")
125131
if mark["type"] not in _MARK_TYPES:
126132
return _marker(node, unsupported)
127-
allowed = {"type", "attrs"} if mark["type"] == "link" else {"type"}
133+
allowed = {"type", "attrs"} if mark["type"] in _ATTR_MARKS else {"type"}
128134
if _has_unknown_keys(mark, allowed):
129135
return _marker(node, unsupported)
130136

@@ -133,6 +139,7 @@ def _render_marked_text(value, marks, node, unsupported):
133139
if any(mark["type"] == "code" for mark in marks)
134140
else _escape_text(value)
135141
)
142+
style_declarations = []
136143
for mark in marks:
137144
mark_type = mark["type"]
138145
if mark_type == "code":
@@ -154,6 +161,16 @@ def _render_marked_text(value, marks, node, unsupported):
154161
):
155162
return _marker(node, unsupported)
156163
rendered = f"[{rendered}]({_escape_destination(attrs['href'])})"
164+
elif mark_type in _STYLE_PROPERTY:
165+
attrs = _attrs(mark, f"{mark_type} mark")
166+
if _has_unknown_keys(attrs, {"color"}) or not isinstance(
167+
attrs.get("color"), str
168+
):
169+
return _marker(node, unsupported)
170+
style_declarations.append(f"{_STYLE_PROPERTY[mark_type]}:{attrs['color']}")
171+
if style_declarations:
172+
style_value = _escape_title("; ".join(style_declarations))
173+
rendered = f'[{rendered}]{{style="{style_value}"}}'
157174
return rendered
158175

159176

‎substack/mdrender.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
from markdown_it import MarkdownIt
2929
from markdown_it.tree import SyntaxTreeNode
30+
from mdit_py_plugins.attrs import attrs_plugin
3031
from mdit_py_plugins.container import container_plugin
3132
from mdit_py_plugins.dollarmath import dollarmath_plugin
3233
from mdit_py_plugins.footnote import footnote_plugin
@@ -141,6 +142,10 @@ def _make_parser() -> MarkdownIt:
141142
.use(superscript_plugin)
142143
.use(container_plugin, name="pullquote")
143144
.use(container_plugin, name="callout")
145+
# Pandoc-style span attributes, `[text]{style="color:#hex"}`, for text
146+
# color and highlight. Restricted to spans only (`after=()`) so a
147+
# stray `{...}` after an image, link or inline code is left as text.
148+
.use(attrs_plugin, after=(), spans=True)
144149
.enable("strikethrough")
145150
)
146151

@@ -161,6 +166,28 @@ def _coalesce(out_nodes: List[Dict]) -> List[Dict]:
161166
return merged
162167

163168

169+
def _style_marks(attrs: Dict) -> List[Dict]:
170+
"""Map a span's `style` attribute to textStyle/highlight marks.
171+
172+
Only `color` and `background-color` declarations are recognized; any
173+
other declaration is ignored rather than rejected, since `{...}` spans
174+
are also used (via the attrs plugin) for plain CSS-style attributes in
175+
general, not just color.
176+
"""
177+
style = attrs.get("style", "")
178+
marks: List[Dict] = []
179+
for declaration in style.split(";"):
180+
prop, _, value = declaration.partition(":")
181+
prop, value = prop.strip().lower(), value.strip()
182+
if not value:
183+
continue
184+
if prop == "color":
185+
marks.append(nodes.text_style_mark(value))
186+
elif prop == "background-color":
187+
marks.append(nodes.highlight_mark(value))
188+
return marks
189+
190+
164191
def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[Dict]:
165192
"""Render an inline subtree into a flat list of text / anchor nodes."""
166193
out: List[Dict] = []
@@ -178,6 +205,8 @@ def _render_inline(node: SyntaxTreeNode, marks: List[Dict], ctx: Dict) -> List[D
178205
elif t == "link":
179206
href = child.attrs.get("href", "")
180207
out.extend(_render_inline(child, marks + [nodes.link_mark(href)], ctx))
208+
elif t == "span":
209+
out.extend(_render_inline(child, marks + _style_marks(child.attrs), ctx))
181210
elif t in ("softbreak", "hardbreak"):
182211
out.append(nodes.text(" ", marks))
183212
elif t == "footnote_ref":

‎substack/nodes.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ class MarkType:
4747
SUPERSCRIPT = "superscript"
4848
SUBSCRIPT = "subscript"
4949
LINK = "link"
50+
TEXT_STYLE = "textStyle"
51+
HIGHLIGHT = "highlight"
5052

5153

5254
def code_mark() -> Dict:
@@ -64,6 +66,14 @@ def link_mark(href: str) -> Dict:
6466
return {"type": MarkType.LINK, "attrs": {"href": href}}
6567

6668

69+
def text_style_mark(color: str) -> Dict:
70+
return {"type": MarkType.TEXT_STYLE, "attrs": {"color": color}}
71+
72+
73+
def highlight_mark(color: str) -> Dict:
74+
return {"type": MarkType.HIGHLIGHT, "attrs": {"color": color}}
75+
76+
6777
def paragraph(content: Optional[List[Dict]] = None) -> Dict:
6878
return {"type": NodeType.PARAGRAPH, "content": content or []}
6979

‎tests/substack/test_from_markdown_features.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,34 @@ def test_link(self):
6060
{"type": "link", "attrs": {"href": "https://e.com"}}
6161
]
6262

63+
def test_text_color(self):
64+
post = make_post()
65+
post.from_markdown('x [c]{style="color:#ff00ff"} y')
66+
assert marked(first_para_nodes(post), "c") == [
67+
{"type": "textStyle", "attrs": {"color": "#ff00ff"}}
68+
]
69+
70+
def test_highlight(self):
71+
post = make_post()
72+
post.from_markdown('x [h]{style="background-color:#ffff00"} y')
73+
assert marked(first_para_nodes(post), "h") == [
74+
{"type": "highlight", "attrs": {"color": "#ffff00"}}
75+
]
76+
77+
def test_text_color_and_highlight_combined(self):
78+
post = make_post()
79+
post.from_markdown('x [both]{style="color:#fff; background-color:#000"} y')
80+
marks = marked(first_para_nodes(post), "both")
81+
assert {"type": "textStyle", "attrs": {"color": "#fff"}} in marks
82+
assert {"type": "highlight", "attrs": {"color": "#000"}} in marks
83+
84+
def test_unrecognized_style_declaration_is_ignored(self):
85+
post = make_post()
86+
post.from_markdown('x [p]{style="font-weight:bold"} y')
87+
# No recognized color/background-color, so the span adds no marks and
88+
# merges back into plain surrounding text.
89+
assert first_para_nodes(post) == [{"type": "text", "text": "x p y"}]
90+
6391
def test_multiple_marks_in_one_paragraph(self):
6492
post = make_post()
6593
post.from_markdown("**b** and *i* and `c` and [l](https://e.com)")

‎tests/substack/test_markdown_export.py‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ def _paragraph(value):
2323
return {"type": "paragraph", "content": [_text(value)]}
2424

2525

26+
def _paragraph_with(*inline_nodes):
27+
return {"type": "paragraph", "content": list(inline_nodes)}
28+
29+
2630
def test_document_to_markdown_golden_covers_supported_nodes():
2731
image = captioned_image(
2832
"https://example.com/image.png",
@@ -161,6 +165,86 @@ def test_document_to_markdown_golden_covers_supported_nodes():
161165
]
162166

163167

168+
def test_document_to_markdown_renders_text_color():
169+
document = {
170+
"type": "doc",
171+
"content": [
172+
_paragraph_with(
173+
_text("c", [{"type": "textStyle", "attrs": {"color": "#ff00ff"}}])
174+
)
175+
],
176+
}
177+
markdown, unsupported = document_to_markdown(document)
178+
assert unsupported == []
179+
assert markdown == '[c]{style="color:#ff00ff"}\n'
180+
181+
182+
def test_document_to_markdown_renders_highlight():
183+
document = {
184+
"type": "doc",
185+
"content": [
186+
_paragraph_with(
187+
_text("h", [{"type": "highlight", "attrs": {"color": "#ffff00"}}])
188+
)
189+
],
190+
}
191+
markdown, unsupported = document_to_markdown(document)
192+
assert unsupported == []
193+
assert markdown == '[h]{style="background-color:#ffff00"}\n'
194+
195+
196+
def test_document_to_markdown_renders_combined_color_and_highlight():
197+
document = {
198+
"type": "doc",
199+
"content": [
200+
_paragraph_with(
201+
_text(
202+
"both",
203+
[
204+
{"type": "textStyle", "attrs": {"color": "#fff"}},
205+
{"type": "highlight", "attrs": {"color": "#000"}},
206+
],
207+
)
208+
)
209+
],
210+
}
211+
markdown, unsupported = document_to_markdown(document)
212+
assert unsupported == []
213+
assert markdown == '[both]{style="color:#fff; background-color:#000"}\n'
214+
215+
216+
@pytest.mark.parametrize(
217+
"mark",
218+
[
219+
{"type": "textStyle", "attrs": {}},
220+
{"type": "highlight", "attrs": {"color": 1}},
221+
{"type": "textStyle", "attrs": {"color": "#fff", "extra": True}},
222+
],
223+
)
224+
def test_document_to_markdown_treats_malformed_style_mark_as_opaque(mark):
225+
document = {
226+
"type": "doc",
227+
"content": [_paragraph_with(_text("c", [mark]))],
228+
}
229+
markdown, unsupported = document_to_markdown(document)
230+
assert len(unsupported) == 1
231+
assert markdown.startswith("<!-- python-substack-node:v1 ")
232+
233+
234+
def test_color_and_highlight_round_trip_through_markdown():
235+
markdown = (
236+
'A [colorized]{style="color:#ff00ff"} and '
237+
'[highlighted]{style="background-color:#ffff00"} word'
238+
)
239+
post = Post("T", "", user_id=1)
240+
post.from_markdown(markdown)
241+
242+
rendered, unsupported = document_to_markdown(post.draft_body)
243+
244+
assert unsupported == []
245+
assert markdown_to_doc(rendered) == post.draft_body["content"]
246+
247+
164248
def test_full_feature_fixture_round_trips_exactly():
165249
fixture = Path(__file__).parent / "fixtures" / "full_features.md"
166250
post = Post("Feature complete", "", user_id=1)

0 commit comments

Comments
 (0)