chromamark 0.1.1__tar.gz

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.
@@ -0,0 +1,15 @@
1
+ Copyright 2026 CJ Fravel
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ 1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ 2. **SaaS Provision**: If you use this software to provide a Software-as-a-Service (SaaS) offering, you must provide access to the source code of the modified version of the Software that you are using in your service, under the terms of this license. The access must be provided within a reasonable time, but no later than 30 days, and the access method should be publicly documented.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10
+
11
+ ---
12
+
13
+ ## Third-party components
14
+
15
+ The published browser bundles in `packages/renderer/dist/` and the bundled VS Code extension embed [markdown-it](https://github.com/markdown-it/markdown-it), which is licensed under the [MIT License](https://github.com/markdown-it/markdown-it/blob/master/LICENSE).
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: chromamark
3
+ Version: 0.1.1
4
+ Summary: ChromaMark for Python — render and build colored blocks, pills, collapsibles, fields, meters and inline diff on top of Markdown, with Jupyter display.
5
+ Author: cjfravel-dev
6
+ License: Copyright 2026 CJ Fravel
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
9
+
10
+ 1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
11
+
12
+ 2. **SaaS Provision**: If you use this software to provide a Software-as-a-Service (SaaS) offering, you must provide access to the source code of the modified version of the Software that you are using in your service, under the terms of this license. The access must be provided within a reasonable time, but no later than 30 days, and the access method should be publicly documented.
13
+
14
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
15
+
16
+ ---
17
+
18
+ ## Third-party components
19
+
20
+ The published browser bundles in `packages/renderer/dist/` and the bundled VS Code extension embed [markdown-it](https://github.com/markdown-it/markdown-it), which is licensed under the [MIT License](https://github.com/markdown-it/markdown-it/blob/master/LICENSE).
21
+
22
+ Project-URL: Homepage, https://github.com/cjfravel-dev/ChromaMark
23
+ Project-URL: Source, https://github.com/cjfravel-dev/ChromaMark
24
+ Project-URL: Issues, https://github.com/cjfravel-dev/ChromaMark/issues
25
+ Keywords: chromamark,markdown,markdown-it,jupyter,report,agent
26
+ Classifier: Programming Language :: Python :: 3
27
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
28
+ Classifier: Framework :: Jupyter
29
+ Requires-Python: >=3.9
30
+ Description-Content-Type: text/markdown
31
+ License-File: LICENSE.md
32
+ Requires-Dist: markdown-it-py[linkify]>=3.0
33
+ Provides-Extra: test
34
+ Requires-Dist: pytest; extra == "test"
35
+ Dynamic: license-file
36
+
37
+ # chromamark (Python)
38
+
39
+ Render and build [ChromaMark](https://github.com/cjfravel-dev/ChromaMark) from
40
+ Python — colored blocks, pills, collapsible sections, fields, meters, and inline
41
+ diff on top of Markdown (CommonMark + GFM). Produces the **same HTML** as the JS
42
+ renderer, so the same theme applies, and it displays inline in Jupyter.
43
+
44
+ Built on [markdown-it-py](https://github.com/executablebooks/markdown-it-py).
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install chromamark
50
+ ```
51
+
52
+ ## Render
53
+
54
+ ```python
55
+ from chromamark import render, create_renderer
56
+
57
+ html = render("::: success\nAll good [!ok pass]\n:::")
58
+
59
+ # or as a markdown-it-py plugin:
60
+ from markdown_it import MarkdownIt
61
+ from chromamark import chromamark_plugin
62
+ md = MarkdownIt("commonmark").use(chromamark_plugin)
63
+ ```
64
+
65
+ ## Build (fluent, for agent reports)
66
+
67
+ ```python
68
+ from chromamark import ChromaDoc
69
+
70
+ doc = ChromaDoc()
71
+ doc.heading("Deploy report")
72
+ doc.success("Deploy succeeded", "3/3 replicas healthy")
73
+ doc.fields(Region="eastus", Status=doc.pill("ok", "healthy"))
74
+ doc.table(["Stage", "Result"], [["unit", doc.pill("pass", "247")]])
75
+ print(doc.to_cm()) # ChromaMark source
76
+ print(doc.to_html()) # rendered HTML
77
+ ```
78
+
79
+ ## Jupyter
80
+
81
+ ```python
82
+ from chromamark import display_chromamark
83
+ display_chromamark("::: success\nRun complete [=success 100%]\n:::")
84
+ ```
85
+
86
+ `ChromaDoc` also renders itself in notebooks via `_repr_html_`.
87
+
88
+ ## Parity with the JavaScript renderer
89
+
90
+ `chromamark` produces byte-identical HTML to `@chromamark/renderer` for normal
91
+ content — verified by a differential harness over ~140,000 inputs (every
92
+ ChromaMark construct, GFM, linkify, and the full SPEC/demo documents all match
93
+ exactly). Three **exotic** edge cases differ, all involving unusual whitespace or
94
+ non-BMP characters; none affect typical agent-generated reports:
95
+
96
+ - **Non-ASCII whitespace at a block edge** (e.g. a leading U+3000 ideographic
97
+ space): JS markdown-it preserves it (`asciiTrim`); markdown-it-py strips all
98
+ Unicode whitespace. Upstream base-engine difference.
99
+ - **Six control/format code points inside ChromaMark constructs**
100
+ (U+001C–U+001F, U+0085 NEL, U+FEFF BOM): counted as whitespace by one engine's
101
+ regex/`strip` but not the other, which can flip pill/field parsing.
102
+ - **Astral-plane (emoji) domain labels** in bare links (e.g. `🎉.com`): auto-linked
103
+ by JS linkify-it but not linkify-it-py. BMP and IDN letter hosts match.
104
+
105
+ ## Credits
106
+
107
+ Inline change-tracking syntax is adopted from **[CriticMarkup](http://criticmarkup.com/)**
108
+ (© 2013 Gabe Weatherhead & Erik Hess, Apache-2.0); ChromaMark's parser is an original,
109
+ independent implementation. See the
110
+ [main README](https://github.com/cjfravel-dev/ChromaMark#prior-art--credits) for full credits.
111
+
112
+ ## License
113
+
114
+ Modified MIT License with a SaaS source-availability provision — see LICENSE.md.
@@ -0,0 +1,78 @@
1
+ # chromamark (Python)
2
+
3
+ Render and build [ChromaMark](https://github.com/cjfravel-dev/ChromaMark) from
4
+ Python — colored blocks, pills, collapsible sections, fields, meters, and inline
5
+ diff on top of Markdown (CommonMark + GFM). Produces the **same HTML** as the JS
6
+ renderer, so the same theme applies, and it displays inline in Jupyter.
7
+
8
+ Built on [markdown-it-py](https://github.com/executablebooks/markdown-it-py).
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install chromamark
14
+ ```
15
+
16
+ ## Render
17
+
18
+ ```python
19
+ from chromamark import render, create_renderer
20
+
21
+ html = render("::: success\nAll good [!ok pass]\n:::")
22
+
23
+ # or as a markdown-it-py plugin:
24
+ from markdown_it import MarkdownIt
25
+ from chromamark import chromamark_plugin
26
+ md = MarkdownIt("commonmark").use(chromamark_plugin)
27
+ ```
28
+
29
+ ## Build (fluent, for agent reports)
30
+
31
+ ```python
32
+ from chromamark import ChromaDoc
33
+
34
+ doc = ChromaDoc()
35
+ doc.heading("Deploy report")
36
+ doc.success("Deploy succeeded", "3/3 replicas healthy")
37
+ doc.fields(Region="eastus", Status=doc.pill("ok", "healthy"))
38
+ doc.table(["Stage", "Result"], [["unit", doc.pill("pass", "247")]])
39
+ print(doc.to_cm()) # ChromaMark source
40
+ print(doc.to_html()) # rendered HTML
41
+ ```
42
+
43
+ ## Jupyter
44
+
45
+ ```python
46
+ from chromamark import display_chromamark
47
+ display_chromamark("::: success\nRun complete [=success 100%]\n:::")
48
+ ```
49
+
50
+ `ChromaDoc` also renders itself in notebooks via `_repr_html_`.
51
+
52
+ ## Parity with the JavaScript renderer
53
+
54
+ `chromamark` produces byte-identical HTML to `@chromamark/renderer` for normal
55
+ content — verified by a differential harness over ~140,000 inputs (every
56
+ ChromaMark construct, GFM, linkify, and the full SPEC/demo documents all match
57
+ exactly). Three **exotic** edge cases differ, all involving unusual whitespace or
58
+ non-BMP characters; none affect typical agent-generated reports:
59
+
60
+ - **Non-ASCII whitespace at a block edge** (e.g. a leading U+3000 ideographic
61
+ space): JS markdown-it preserves it (`asciiTrim`); markdown-it-py strips all
62
+ Unicode whitespace. Upstream base-engine difference.
63
+ - **Six control/format code points inside ChromaMark constructs**
64
+ (U+001C–U+001F, U+0085 NEL, U+FEFF BOM): counted as whitespace by one engine's
65
+ regex/`strip` but not the other, which can flip pill/field parsing.
66
+ - **Astral-plane (emoji) domain labels** in bare links (e.g. `🎉.com`): auto-linked
67
+ by JS linkify-it but not linkify-it-py. BMP and IDN letter hosts match.
68
+
69
+ ## Credits
70
+
71
+ Inline change-tracking syntax is adopted from **[CriticMarkup](http://criticmarkup.com/)**
72
+ (© 2013 Gabe Weatherhead & Erik Hess, Apache-2.0); ChromaMark's parser is an original,
73
+ independent implementation. See the
74
+ [main README](https://github.com/cjfravel-dev/ChromaMark#prior-art--credits) for full credits.
75
+
76
+ ## License
77
+
78
+ Modified MIT License with a SaaS source-availability provision — see LICENSE.md.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "chromamark"
7
+ version = "0.1.1"
8
+ description = "ChromaMark for Python — render and build colored blocks, pills, collapsibles, fields, meters and inline diff on top of Markdown, with Jupyter display."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { file = "LICENSE.md" }
12
+ authors = [{ name = "cjfravel-dev" }]
13
+ keywords = ["chromamark", "markdown", "markdown-it", "jupyter", "report", "agent"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Text Processing :: Markup :: Markdown",
17
+ "Framework :: Jupyter",
18
+ ]
19
+ dependencies = ["markdown-it-py[linkify]>=3.0"]
20
+
21
+ [project.optional-dependencies]
22
+ test = ["pytest"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/cjfravel-dev/ChromaMark"
26
+ Source = "https://github.com/cjfravel-dev/ChromaMark"
27
+ Issues = "https://github.com/cjfravel-dev/ChromaMark/issues"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [tool.setuptools.package-data]
33
+ chromamark = ["theme.css"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,49 @@
1
+ """ChromaMark for Python — renderer, builder, and Jupyter display.
2
+
3
+ Produces the same HTML as the JavaScript ``@chromamark/renderer`` so the same
4
+ theme applies. Built on markdown-it-py.
5
+ """
6
+
7
+ import importlib.resources as _resources
8
+
9
+ from markdown_it import MarkdownIt
10
+
11
+ from .builder import ChromaDoc
12
+ from .notebook import ChromaMarkHTML, display_chromamark
13
+ from .plugin import chromamark_plugin
14
+ from .tones import TONES, is_safe_color, parse_spec, resolve_tone
15
+
16
+ __version__ = "0.1.1"
17
+
18
+
19
+ def create_renderer(**options):
20
+ """Return a markdown-it-py instance configured for ChromaMark (CommonMark + GFM)."""
21
+ md = MarkdownIt("js-default", {"html": False, "linkify": True}).enable("linkify")
22
+ md.use(chromamark_plugin, **options)
23
+ return md
24
+
25
+
26
+ def render(src, **options):
27
+ """Render a ChromaMark string to an HTML fragment."""
28
+ return create_renderer(**options).render("" if src is None else str(src))
29
+
30
+
31
+ def get_theme():
32
+ """Return the ChromaMark theme stylesheet as a string."""
33
+ return (_resources.files("chromamark") / "theme.css").read_text(encoding="utf-8")
34
+
35
+
36
+ __all__ = [
37
+ "render",
38
+ "create_renderer",
39
+ "chromamark_plugin",
40
+ "get_theme",
41
+ "ChromaDoc",
42
+ "display_chromamark",
43
+ "ChromaMarkHTML",
44
+ "resolve_tone",
45
+ "parse_spec",
46
+ "is_safe_color",
47
+ "TONES",
48
+ "__version__",
49
+ ]
@@ -0,0 +1,120 @@
1
+ """ChromaDoc — a fluent builder that emits ChromaMark source (and renders it)."""
2
+
3
+ import re
4
+
5
+ _FENCE_RUN = re.compile(r"(?m)^\s*(:{3,})")
6
+
7
+
8
+ class ChromaDoc:
9
+ """Assemble a ChromaMark document programmatically.
10
+
11
+ Every method returns ``self`` for chaining. Use :meth:`to_cm` for the
12
+ ChromaMark source, :meth:`to_html` to render, and it renders inline in
13
+ Jupyter via ``_repr_html_``.
14
+ """
15
+
16
+ def __init__(self):
17
+ self._blocks = []
18
+
19
+ # ---- inline helpers (return strings) ----
20
+ def pill(self, tone, label=None):
21
+ return f"[!{tone}]" if label is None else f"[!{tone} {label}]"
22
+
23
+ def tint(self, tone, label):
24
+ return f"[.{tone} {label}]"
25
+
26
+ def meter(self, tone, value):
27
+ return f"[={tone} {value}]"
28
+
29
+ # ---- block builders (append, return self) ----
30
+ def _add(self, chunk):
31
+ self._blocks.append(chunk)
32
+ return self
33
+
34
+ def raw(self, markdown_text):
35
+ return self._add(str(markdown_text))
36
+
37
+ def heading(self, text, level=2):
38
+ return self._add(f"{'#' * max(1, min(6, level))} {text}")
39
+
40
+ def paragraph(self, text):
41
+ return self._add(str(text))
42
+
43
+ def _body_to_cm(self, body):
44
+ if body is None:
45
+ return ""
46
+ if isinstance(body, ChromaDoc):
47
+ return body.to_cm().rstrip("\n")
48
+ return str(body)
49
+
50
+ def _fence_for(self, body_cm):
51
+ longest = 0
52
+ for match in _FENCE_RUN.finditer(body_cm or ""):
53
+ longest = max(longest, len(match.group(1)))
54
+ return ":" * max(3, longest + 1)
55
+
56
+ def _container(self, opener, body):
57
+ body_cm = self._body_to_cm(body)
58
+ fence = self._fence_for(body_cm) if body_cm else ":::"
59
+ head = f"{fence} {opener}"
60
+ if body_cm:
61
+ return f"{head}\n{body_cm}\n{fence}"
62
+ return f"{head}\n{fence}"
63
+
64
+ def block(self, tone, title=None, body=None):
65
+ opener = tone if not title else f"{tone} {title}"
66
+ return self._add(self._container(opener, body))
67
+
68
+ def success(self, title=None, body=None):
69
+ return self.block("success", title, body)
70
+
71
+ def info(self, title=None, body=None):
72
+ return self.block("info", title, body)
73
+
74
+ def tip(self, title=None, body=None):
75
+ return self.block("tip", title, body)
76
+
77
+ def warning(self, title=None, body=None):
78
+ return self.block("warning", title, body)
79
+
80
+ def danger(self, title=None, body=None):
81
+ return self.block("danger", title, body)
82
+
83
+ def muted(self, title=None, body=None):
84
+ return self.block("muted", title, body)
85
+
86
+ def details(self, summary, body=None, open=False, tone=None):
87
+ parts = ["details"]
88
+ if open:
89
+ parts.append("open")
90
+ if tone:
91
+ parts.append(tone)
92
+ parts.append(summary)
93
+ return self._add(self._container(" ".join(parts), body))
94
+
95
+ def fields(self, _map=None, **kwargs):
96
+ rows = {}
97
+ if _map:
98
+ rows.update(_map)
99
+ rows.update(kwargs)
100
+ lines = "\n".join(f"{k}: {v}" for k, v in rows.items())
101
+ fence = self._fence_for(lines)
102
+ return self._add(f"{fence} fields\n{lines}\n{fence}")
103
+
104
+ def table(self, headers, rows):
105
+ head = "| " + " | ".join(str(h) for h in headers) + " |"
106
+ sep = "| " + " | ".join("---" for _ in headers) + " |"
107
+ body = "\n".join("| " + " | ".join(str(c) for c in row) + " |" for row in rows)
108
+ return self._add("\n".join([head, sep, body]))
109
+
110
+ # ---- output ----
111
+ def to_cm(self):
112
+ return "\n\n".join(self._blocks) + "\n"
113
+
114
+ def to_html(self, **options):
115
+ from . import render
116
+ return render(self.to_cm(), **options)
117
+
118
+ def _repr_html_(self):
119
+ from . import get_theme
120
+ return f'<style>{get_theme()}</style>\n<div class="chromamark-output">{self.to_html()}</div>'
@@ -0,0 +1,198 @@
1
+ """Block-level ChromaMark containers: ::: callout / details / fields."""
2
+
3
+ from markdown_it.common.utils import escapeHtml
4
+
5
+ from .tones import parse_spec, resolve_tone
6
+
7
+ MIN_FENCE = 3
8
+
9
+
10
+ def _cap(s):
11
+ return s[:1].upper() + s[1:]
12
+
13
+
14
+ def _parse_opener(info):
15
+ if not info:
16
+ return None
17
+ tokens = info.split()
18
+ kind = tokens.pop(0).lower()
19
+
20
+ tone = None
21
+ color = None
22
+ is_open = False
23
+
24
+ if kind == "details":
25
+ structure = "details"
26
+ elif kind == "fields":
27
+ return {"structure": "fields"}
28
+ elif kind == "block":
29
+ structure = "callout"
30
+ else:
31
+ t = resolve_tone(kind)
32
+ if not t:
33
+ return None
34
+ structure = "callout"
35
+ tone = t
36
+
37
+ while tokens:
38
+ tk = tokens[0]
39
+ low = tk.lower()
40
+ if structure == "details" and low == "open":
41
+ is_open = True
42
+ tokens.pop(0)
43
+ continue
44
+ if low.startswith("color="):
45
+ spec = parse_spec(tk)
46
+ if spec and spec["color"]:
47
+ color = spec["color"]
48
+ tokens.pop(0)
49
+ continue
50
+ break
51
+ if structure == "details" and tone is None and resolve_tone(tk):
52
+ tone = resolve_tone(tk)
53
+ tokens.pop(0)
54
+ continue
55
+ break
56
+
57
+ rest = " ".join(tokens).strip()
58
+ if structure == "details":
59
+ return {"structure": "details", "tone": tone, "color": color,
60
+ "open": is_open, "summary": rest or "Details"}
61
+ return {"structure": structure, "tone": tone, "color": color,
62
+ "title": rest or (_cap(tone) if tone else "")}
63
+
64
+
65
+ def _fence_len(src, pos, maximum):
66
+ count = 0
67
+ p = pos
68
+ while p < maximum and src[p] == ":":
69
+ count += 1
70
+ p += 1
71
+ return count
72
+
73
+
74
+ def _make_rule(enabled):
75
+ def chroma_container(state, startLine, endLine, silent):
76
+ start = state.bMarks[startLine] + state.tShift[startLine]
77
+ maximum = state.eMarks[startLine]
78
+ if start >= len(state.src) or state.src[start] != ":":
79
+ return False
80
+
81
+ open_len = _fence_len(state.src, start, maximum)
82
+ if open_len < MIN_FENCE:
83
+ return False
84
+
85
+ parsed = _parse_opener(state.src[start + open_len:maximum].strip())
86
+ if not parsed or not enabled.get(parsed["structure"]):
87
+ return False
88
+ if silent:
89
+ return True
90
+
91
+ next_line = startLine
92
+ auto_closed = False
93
+ while True:
94
+ next_line += 1
95
+ if next_line >= endLine:
96
+ break
97
+ lstart = state.bMarks[next_line] + state.tShift[next_line]
98
+ lmax = state.eMarks[next_line]
99
+ if lstart >= len(state.src) or state.src[lstart] != ":":
100
+ continue
101
+ if state.sCount[next_line] - state.blkIndent >= 4:
102
+ continue
103
+ close_len = _fence_len(state.src, lstart, lmax)
104
+ if close_len < open_len:
105
+ continue
106
+ p = lstart + close_len
107
+ while p < lmax and state.src[p] in (" ", "\t"):
108
+ p += 1
109
+ if p < lmax:
110
+ continue
111
+ auto_closed = True
112
+ break
113
+
114
+ old_parent = state.parentType
115
+ old_line_max = state.lineMax
116
+ state.parentType = "chroma_container"
117
+ state.lineMax = next_line
118
+
119
+ if parsed["structure"] == "fields":
120
+ rows = []
121
+ ln = startLine + 1
122
+ while ln < next_line:
123
+ line = state.src[state.bMarks[ln] + state.tShift[ln]:state.eMarks[ln]]
124
+ if line.strip():
125
+ ci = line.find(":")
126
+ if ci == -1:
127
+ rows.append((line.strip(), ""))
128
+ else:
129
+ rows.append((line[:ci].strip(), line[ci + 1:].strip()))
130
+ ln += 1
131
+ token = state.push("cm_fields", "", 0)
132
+ token.meta = {"rows": rows}
133
+ token.map = [startLine, next_line]
134
+ else:
135
+ open_token = state.push("cm_container_open", "div", 1)
136
+ open_token.meta = parsed
137
+ open_token.block = True
138
+ open_token.map = [startLine, next_line]
139
+
140
+ state.md.block.tokenize(state, startLine + 1, next_line)
141
+
142
+ close_token = state.push("cm_container_close", "div", -1)
143
+ close_token.meta = parsed
144
+ close_token.block = True
145
+
146
+ state.parentType = old_parent
147
+ state.lineMax = old_line_max
148
+ state.line = next_line + (1 if auto_closed else 0)
149
+ return True
150
+
151
+ return chroma_container
152
+
153
+
154
+ def container_plugin(md, enabled):
155
+ md.block.ruler.before(
156
+ "fence", "cm_container", _make_rule(enabled),
157
+ {"alt": ["paragraph", "reference", "blockquote", "list"]},
158
+ )
159
+
160
+ def decorate(meta):
161
+ custom = " cm-custom" if meta.get("color") else ""
162
+ style = f' style="--fg:{escapeHtml(meta["color"])}"' if meta.get("color") else ""
163
+ tone = f' data-tone="{meta["tone"]}"' if (not meta.get("color") and meta.get("tone")) else ""
164
+ return custom, style, tone
165
+
166
+ def render_open(self, tokens, idx, options, env):
167
+ meta = tokens[idx].meta
168
+ custom, style, tone = decorate(meta)
169
+ if meta["structure"] == "details":
170
+ open_attr = " open" if meta.get("open") else ""
171
+ return (
172
+ f'<details class="cm-details{custom}"{tone}{style}{open_attr}>'
173
+ f'<summary>{escapeHtml(meta["summary"])}</summary><div class="cm-body">'
174
+ )
175
+ html = f'<div class="cm-block{custom}"{tone}{style}>'
176
+ if meta.get("title"):
177
+ html += f'<div class="cm-title">{escapeHtml(meta["title"])}</div>'
178
+ return html + '<div class="cm-body">'
179
+
180
+ def render_close(self, tokens, idx, options, env):
181
+ return "</div></details>" if tokens[idx].meta["structure"] == "details" else "</div></div>"
182
+
183
+ def render_fields(self, tokens, idx, options, env):
184
+ # Force html:false around renderInline so field values are escaped
185
+ # regardless of the host markdown-it configuration (defense in depth).
186
+ prev = md.options["html"]
187
+ md.options["html"] = False
188
+ try:
189
+ html = '<dl class="cm-fields">'
190
+ for key, value in tokens[idx].meta["rows"]:
191
+ html += f'<dt>{escapeHtml(key)}</dt><dd>{md.renderInline(value)}</dd>'
192
+ return html + "</dl>"
193
+ finally:
194
+ md.options["html"] = prev
195
+
196
+ md.add_render_rule("cm_container_open", render_open)
197
+ md.add_render_rule("cm_container_close", render_close)
198
+ md.add_render_rule("cm_fields", render_fields)
@@ -0,0 +1,67 @@
1
+ """Inline diff via CriticMarkup: {++add++} {--del--} {~~old~>new~~} {==mark==} {>>comment<<}."""
2
+
3
+ from markdown_it.common.utils import escapeHtml
4
+
5
+ KINDS = {
6
+ "++": ("add", "++}"),
7
+ "--": ("del", "--}"),
8
+ "~~": ("sub", "~~}"),
9
+ "==": ("mark", "==}"),
10
+ ">>": ("comment", "<<}"),
11
+ }
12
+
13
+
14
+ def _rule(state, silent):
15
+ start = state.pos
16
+ src = state.src
17
+ if start >= state.posMax or src[start] != "{":
18
+ return False
19
+ spec = KINDS.get(src[start + 1:start + 3])
20
+ if not spec:
21
+ return False
22
+ kind, close = spec
23
+
24
+ content_start = start + 3
25
+ close_idx = src.find(close, content_start)
26
+ if close_idx == -1 or close_idx + len(close) > state.posMax:
27
+ return False
28
+
29
+ raw = src[content_start:close_idx]
30
+ if not silent:
31
+ tok = state.push("cm_critic", "", 0)
32
+ if kind == "sub":
33
+ cut = raw.find("~>")
34
+ tok.meta = {
35
+ "kind": "sub",
36
+ "old": raw if cut == -1 else raw[:cut],
37
+ "neu": "" if cut == -1 else raw[cut + 2:],
38
+ }
39
+ else:
40
+ tok.meta = {"kind": kind, "content": raw}
41
+
42
+ state.pos = close_idx + len(close)
43
+ return True
44
+
45
+
46
+ def critic_plugin(md):
47
+ md.inline.ruler.before("emphasis", "cm_critic", _rule)
48
+
49
+ def render_critic(self, tokens, idx, options, env):
50
+ meta = tokens[idx].meta
51
+ kind = meta["kind"]
52
+ if kind == "add":
53
+ return f'<ins class="crit-add">{escapeHtml(meta["content"])}</ins>'
54
+ if kind == "del":
55
+ return f'<del class="crit-del">{escapeHtml(meta["content"])}</del>'
56
+ if kind == "sub":
57
+ return (
58
+ f'<del class="crit-del">{escapeHtml(meta["old"])}</del>'
59
+ f'<ins class="crit-add">{escapeHtml(meta["neu"])}</ins>'
60
+ )
61
+ if kind == "mark":
62
+ return f'<mark class="crit-mark">{escapeHtml(meta["content"])}</mark>'
63
+ if kind == "comment":
64
+ return f'<span class="crit-comment">{escapeHtml(meta["content"])}</span>'
65
+ return ""
66
+
67
+ md.add_render_rule("cm_critic", render_critic)