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.
- chromamark-0.1.1/LICENSE.md +15 -0
- chromamark-0.1.1/PKG-INFO +114 -0
- chromamark-0.1.1/README.md +78 -0
- chromamark-0.1.1/pyproject.toml +33 -0
- chromamark-0.1.1/setup.cfg +4 -0
- chromamark-0.1.1/src/chromamark/__init__.py +49 -0
- chromamark-0.1.1/src/chromamark/builder.py +120 -0
- chromamark-0.1.1/src/chromamark/containers.py +198 -0
- chromamark-0.1.1/src/chromamark/critic.py +67 -0
- chromamark-0.1.1/src/chromamark/inline.py +148 -0
- chromamark-0.1.1/src/chromamark/notebook.py +40 -0
- chromamark-0.1.1/src/chromamark/plugin.py +32 -0
- chromamark-0.1.1/src/chromamark/theme.css +113 -0
- chromamark-0.1.1/src/chromamark/tones.py +55 -0
- chromamark-0.1.1/src/chromamark.egg-info/PKG-INFO +114 -0
- chromamark-0.1.1/src/chromamark.egg-info/SOURCES.txt +25 -0
- chromamark-0.1.1/src/chromamark.egg-info/dependency_links.txt +1 -0
- chromamark-0.1.1/src/chromamark.egg-info/requires.txt +4 -0
- chromamark-0.1.1/src/chromamark.egg-info/top_level.txt +1 -0
- chromamark-0.1.1/tests/test_builder.py +62 -0
- chromamark-0.1.1/tests/test_containers.py +83 -0
- chromamark-0.1.1/tests/test_critic.py +36 -0
- chromamark-0.1.1/tests/test_inline.py +65 -0
- chromamark-0.1.1/tests/test_notebook.py +17 -0
- chromamark-0.1.1/tests/test_parity.py +35 -0
- chromamark-0.1.1/tests/test_render.py +59 -0
- chromamark-0.1.1/tests/test_tones.py +47 -0
|
@@ -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,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)
|