diff --git a/.gitignore b/.gitignore index 4906f4c..d82137c 100644 --- a/.gitignore +++ b/.gitignore @@ -117,6 +117,7 @@ venv/ ENV/ env.bak/ venv.bak/ +.venv/ # Spyder project settings .spyderproject diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1c29524..88f1c3a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,7 +37,7 @@ repos: rev: v0.17.2 hooks: - id: markdownlint-cli2 - exclude: tests/data/test_1.md + exclude: tests/data/.*.md - repo: https://github.com/codespell-project/codespell diff --git a/docs/README.md b/docs/README.md index 0c39f9a..893af9a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -80,6 +80,43 @@ graph TB ``` +### Adding Custom Icon Packs + +Some newer MermaidJS diagram types (most notably [Architecture](https://mermaid.js.org/syntax/architecture.html)), +support referencing custom icon packs that are registered (i.e. https://mermaid.js.org/config/icons.html). + +To register packs, you can add them to the extension config with a structure of ```icon_packs: {"pack_name" : "pack_url" }```, i.e.: + +```python +import markdown + +html = markdown.markdown( + text, + extensions=["markdown-mermaidjs"], + extension_configs={ + "markdown_mermaidjs": { + "icon_packs": { + "logos": "https://unpkg.com/@iconify-json/logos@1/icons.json", + "hugeicons": "https://unpkg.com/@iconify-json/hugeicons@1/icons.json", + } + } + }, +) +``` + +The resulting HTML should be nearly identical, but the icon packs should be registered, e.g.: + +```html + +``` + ### Use it with [Pelican](https://getpelican.com/) Add `"markdown_mermaidjs": {}` to `MARKDOWN["extension_configs"]` in your `pelicanconf.py`. @@ -97,6 +134,23 @@ MARKDOWN = { } ``` +#### Icon Packs via Pelican + +Similarly, with the extension config, you can add it in the `pelicanconf.py`. + +```python +MARKDOWN = { + "extension_configs": { + "markdown_mermaidjs": { + "icon_packs": { + "logos": "https://unpkg.com/@iconify-json/logos@1/icons.json", + "hugeicons": "https://unpkg.com/@iconify-json/hugeicons@1/icons.json", + } + }, + }, +} +``` + ## Contributing See [Contributing](contributing.md) diff --git a/docs/contributing.md b/docs/contributing.md index b68871c..6617850 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -32,6 +32,11 @@ python -m pipx ensurepath * [uv](https://github.com/astral-sh/uv/): for dependency management * [invoke](https://github.com/pyinvoke/invoke): for task management +#### Notes for Windows Developers + +By default, the tasks leverage [pty](https://docs.python.org/3/library/pty.html) under the covers, which +does not support Windows. Either run this via WSL or pass --no-pty to commands that demand it. + ### Step 6. Create your local Python virtual environment and install dependencies ```sh @@ -45,6 +50,12 @@ Note that this project follows [conventional-commit](https://www.conventionalcom inv git.commit ``` +or on Windows (currently throws an error when run outside of cmd.exe): + +```sh +inv git.commit --no-pty +``` + ### Step 8. Run test cases Make sure all test cases pass. @@ -52,6 +63,12 @@ Make sure all test cases pass. inv test ``` +or on Windows: + +```sh +inv test --no-pty +``` + ### Step 9. Run test coverage Check the test coverage and see where you can add test cases. @@ -82,4 +99,10 @@ Ensure the packages installed are secure, and no server vulnerability is introdu inv secure ``` +or on Windows: + +```sh +inv secure --no-pty +``` + ### Step 13. Create a Pull Request and celebrate 🎉 diff --git a/markdown_mermaidjs/markdown_mermaidjs.py b/markdown_mermaidjs/markdown_mermaidjs.py index a7d0130..338bac3 100644 --- a/markdown_mermaidjs/markdown_mermaidjs.py +++ b/markdown_mermaidjs/markdown_mermaidjs.py @@ -10,57 +10,105 @@ from markdown import Markdown -MERMAID_CODEBLOCK_START = re.compile(r"^(?P[\~\`]{3})[Mm]ermaid\s*$") -MERMAID_JS_SCRIPT = """ +class MermaidPreprocessor(Preprocessor): + MERMAID_CODEBLOCK_START = re.compile( + r"^(?P[\~\`]{3})[Mm]ermaid\s*$" + ) + + def __init__(self, md: Markdown, icon_packs: dict | None = None) -> None: + self.icon_packs = icon_packs + super().__init__(md) + + @property + def icon_packs_calls(self) -> list[str]: + return ( + [ + f"{{ name: '{name}', loader: () => fetch('{url}').then((res) => res.json()) }}" + for name, url in self.icon_packs.items() + ] + if self.icon_packs + else [] + ) + + def generate_mermaid_init_script(self) -> list[str]: + icon_packs_calls = "" + + calls = self.icon_packs_calls + if len(calls): + callstr = "\n,".join(calls) + icon_packs_calls = f""" +mermaid.registerIconPacks([ + {callstr} +]);""" + + script_module = f""" """ - -def add_mermaid_script_and_tag(lines: list[str]) -> list[str]: - result_lines: list[str] = [] - in_mermaid_codeblock: bool = False - exist_mermaid_codeblock: bool = False - - codeblock_end_pattern = re.compile("```") - for line in lines: - if in_mermaid_codeblock: - match_codeblock_end = codeblock_end_pattern.match(line) - if match_codeblock_end: - in_mermaid_codeblock = False - result_lines.append("") + return script_module.split("\n") + + def add_mermaid_script_and_tag(self, lines: list[str]) -> list[str]: + result_lines: list[str] = [] + in_mermaid_codeblock: bool = False + exist_mermaid_codeblock: bool = False + + codeblock_end_pattern = re.compile("```") + for line in lines: + if in_mermaid_codeblock: + match_codeblock_end = codeblock_end_pattern.match(line) + if match_codeblock_end: + in_mermaid_codeblock = False + result_lines.append("") + continue + + match_mermaid_codeblock_start = self.MERMAID_CODEBLOCK_START.match(line) + if match_mermaid_codeblock_start: + exist_mermaid_codeblock = True + in_mermaid_codeblock = True + codeblock_sign = match_mermaid_codeblock_start.group("code_block_sign") + codeblock_end_pattern = re.compile(rf"{codeblock_sign}\s*") + result_lines.append('
') continue - match_mermaid_codeblock_start = MERMAID_CODEBLOCK_START.match(line) - if match_mermaid_codeblock_start: - exist_mermaid_codeblock = True - in_mermaid_codeblock = True - codeblock_sign = match_mermaid_codeblock_start.group("code_block_sign") - codeblock_end_pattern = re.compile(rf"{codeblock_sign}\s*") - result_lines.append('
') - continue - - result_lines.append(line) - - if exist_mermaid_codeblock: - result_lines.extend(MERMAID_JS_SCRIPT.split("\n")) - return result_lines + result_lines.append(line) + if exist_mermaid_codeblock: + result_lines.extend(self.generate_mermaid_init_script()) + return result_lines -class MermaidPreprocessor(Preprocessor): def run(self, lines: list[str]) -> list[str]: - return add_mermaid_script_and_tag(lines) + return self.add_mermaid_script_and_tag(lines) class MermaidExtension(Extension): - """Add source code highlighting to markdown codeblocks.""" + """Add mermaid diagram markdown codeblocks.""" + + def __init__(self, **kwargs: dict[str, Any]) -> None: + self.config = { + "icon_packs": [ + {}, + "Dictionary of icon packs to use: { name(str) : url(str) }. Default: {} (no icon packs). example: { 'logos' : 'https://unpkg.com/@iconify-json/logos@1/icons.json' } corresponds to the json file example here: https://mermaid.js.org/config/icons.html", + ], + } + + super().__init__(**kwargs) + + self.icon_packs: dict[str, str] = {} + config_packs = ( + self.getConfig("icon_packs", default={}) or {} + ) # for the None case + self.icon_packs.update(config_packs) def extendMarkdown(self, md: Markdown) -> None: - """Add HilitePostprocessor to Markdown instance.""" + """Add MermaidExtension to Markdown instance.""" # Insert a preprocessor before ReferencePreprocessor - md.preprocessors.register(MermaidPreprocessor(md), "mermaid", 35) + + md.preprocessors.register( + MermaidPreprocessor(md, icon_packs=self.icon_packs), "mermaid", 35 + ) md.registerExtension(self) diff --git a/pyproject.toml b/pyproject.toml index 591b021..e824a7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,10 @@ [project] name = "markdown-mermaidjs" version = "1.0.0" -authors = [{ name = "Wei Lee", email = "weilee.rx@gmail.com" }] +authors = [ + { name = "Wei Lee", email = "weilee.rx@gmail.com" }, + { name = "Owyn Richen", email = "owynrichen@gmail.com" }, +] description = "Python-Markdown extension to add Mermaid graph" license = "GPL-3.0-only" readme = "docs/README.md" @@ -35,15 +38,14 @@ test = [ "pytest-cov>=6.0.0", "pytest-mock>=3.14.0", ] -style = [ - "mypy>=1.14.1", - "ruff>=0.9.2", - "types-markdown>=3.7.0.20241204", -] +style = ["mypy>=1.14.1", "ruff>=0.9.2", "types-markdown>=3.7.0.20241204"] security = ["bandit>=1.2.2", "pip-audit>=2.7.3"] git = ["commitizen>=4.1.0", "pre-commit>=4.0.1"] doc = ["mkdocs>=1.6.1", "mkdocs-material>=9.5.50"] +[tool.uv] +default-groups = ["dev", "test", "style", "security", "git"] + [tool.commitizen] name = "cz_conventional_commits" diff --git a/tests/data/test_icons_1.md b/tests/data/test_icons_1.md new file mode 100644 index 0000000..6e18064 --- /dev/null +++ b/tests/data/test_icons_1.md @@ -0,0 +1,36 @@ +# Title + +Testing 'registerIconPacks' as used by https://mermaid.js.org/syntax/architecture.html. + +```mermaid +architecture-beta + group api(logos:aws-lambda)[API] + + service db(logos:aws-aurora)[Database] in api + service disk1(logos:aws-glacier)[Storage] in api + service disk2(logos:aws-s3)[Storage] in api + service server(logos:aws-ec2)[Server] in api + + db:L -- R:server + disk1:T -- B:server + disk2:T -- B:db +``` + +It only supports full url lazy-loading: https://mermaid.js.org/config/icons.html + +~~~mermaid +architecture-beta + group api(logos:aws-lambda)[API] + + service db(logos:aws-aurora)[Database] in api + service disk1(logos:aws-glacier)[Storage] in api + service disk2(logos:aws-s3)[Storage] in api + service server(logos:aws-ec2)[Server] in api + + db:L -- R:server + disk1:T -- B:server + disk2:T -- B:db +~~~ + + +End of the file diff --git a/tests/data/test_icons_2.md b/tests/data/test_icons_2.md new file mode 100644 index 0000000..8989e2e --- /dev/null +++ b/tests/data/test_icons_2.md @@ -0,0 +1,5 @@ +# Title + +Some text. + +End of the file diff --git a/tests/test_markdown_mermaid.py b/tests/test_markdown_mermaid.py index 804ae63..7088dfa 100644 --- a/tests/test_markdown_mermaid.py +++ b/tests/test_markdown_mermaid.py @@ -2,9 +2,10 @@ from pathlib import Path +import markdown import pytest -from markdown_mermaidjs.markdown_mermaidjs import add_mermaid_script_and_tag +from markdown_mermaidjs.markdown_mermaidjs import MermaidExtension, MermaidPreprocessor data_dir = Path("tests/data") @@ -15,5 +16,85 @@ def test_add_mermaid_script_and_tag(data_regression, input_file_path): with open(input_file_path) as input_file: lines = input_file.readlines() - result_lines = add_mermaid_script_and_tag(lines) + + mermaid_preprocessor = MermaidPreprocessor(MermaidExtension()) + + result_lines = mermaid_preprocessor.add_mermaid_script_and_tag(lines) + data_regression.check("\n".join(result_lines)) + + +def test_configure_icon_packs(): + mermaid_extension = MermaidExtension( + icon_packs={"logos": "https://unpkg.com/@iconify-json/logos@1/icons.json"} + ) + assert mermaid_extension.icon_packs == { + "logos": "https://unpkg.com/@iconify-json/logos@1/icons.json" + } + + +def test_configure_icon_packs_default(): + mermaid_extension = MermaidExtension() + assert mermaid_extension.icon_packs == {} + + +@pytest.mark.parametrize( + ("input_icon_packs", "expected_output"), + [ + ( + {"logos": "https://unpkg.com/@iconify-json/logos@1/icons.json"}, + {"logos": "https://unpkg.com/@iconify-json/logos@1/icons.json"}, + ), + ( + { + "logos": "https://unpkg.com/@iconify-json/logos@1/icons.json", + "hugeicons": "https://unpkg.com/@iconify-json/hugeicons@1/icons.json", + }, + { + "logos": "https://unpkg.com/@iconify-json/logos@1/icons.json", + "hugeicons": "https://unpkg.com/@iconify-json/hugeicons@1/icons.json", + }, + ), + (None, {}), + ], +) +def test_extension_configuration_icon_packs(input_icon_packs, expected_output): + mermaid_extension = MermaidExtension(icon_packs=input_icon_packs) + + markdown_instance = markdown.Markdown(extensions=[mermaid_extension]) + + mermaid_preprocessor = markdown_instance.preprocessors[0] + assert mermaid_preprocessor.icon_packs == expected_output + + markdown_instance2 = markdown.Markdown( + extensions=["markdown_mermaidjs"], + extension_configs={"markdown_mermaidjs": {"icon_packs": input_icon_packs}}, + ) + + mermaid_preprocessor2 = markdown_instance2.preprocessors[0] + assert mermaid_preprocessor2.icon_packs == expected_output + + +@pytest.mark.parametrize( + "input_file_path", [data_dir / "test_icons_1.md", data_dir / "test_icons_2.md"] +) +def test_add_mermaid_script_and_tag_with_icons(data_regression, input_file_path): + with open(input_file_path) as input_file: + lines = input_file.readlines() + + markdown_instance = markdown.Markdown( + extensions=["markdown_mermaidjs"], + extension_configs={ + "markdown_mermaidjs": { + "icon_packs": { + "logos": "https://unpkg.com/@iconify-json/logos@1/icons.json" + } + } + }, + ) + mermaid_preprocessor = MermaidPreprocessor( + md=markdown_instance, + icon_packs={"logos": "https://unpkg.com/@iconify-json/logos@1/icons.json"}, + ) + + result_lines = mermaid_preprocessor.add_mermaid_script_and_tag(lines) data_regression.check("\n".join(result_lines)) diff --git a/tests/test_markdown_mermaid/test_add_mermaid_script_and_tag_with_icons_input_file_path0_.yml b/tests/test_markdown_mermaid/test_add_mermaid_script_and_tag_with_icons_input_file_path0_.yml new file mode 100644 index 0000000..d4fda07 --- /dev/null +++ b/tests/test_markdown_mermaid/test_add_mermaid_script_and_tag_with_icons_input_file_path0_.yml @@ -0,0 +1,14 @@ +"# Title\n\n\n\nTesting 'registerIconPacks' as used by https://mermaid.js.org/syntax/architecture.html.\n\ + \n\n\n
\narchitecture-beta\n\n group api(logos:aws-lambda)[API]\n\ + \n\n\n service db(logos:aws-aurora)[Database] in api\n\n service disk1(logos:aws-glacier)[Storage]\ + \ in api\n\n service disk2(logos:aws-s3)[Storage] in api\n\n service server(logos:aws-ec2)[Server]\ + \ in api\n\n\n\n db:L -- R:server\n\n disk1:T -- B:server\n\n disk2:T --\ + \ B:db\n\n
\n\n\nIt only supports full url lazy-loading: https://mermaid.js.org/config/icons.html\n\ + \n\n\n
\narchitecture-beta\n\n group api(logos:aws-lambda)[API]\n\ + \n\n\n service db(logos:aws-aurora)[Database] in api\n\n service disk1(logos:aws-glacier)[Storage]\ + \ in api\n\n service disk2(logos:aws-s3)[Storage] in api\n\n service server(logos:aws-ec2)[Server]\ + \ in api\n\n\n\n db:L -- R:server\n\n disk1:T -- B:server\n\n disk2:T --\ + \ B:db\n\n
\n\n\n\n\nEnd of the file\n\n\n\n" diff --git a/tests/test_markdown_mermaid/test_add_mermaid_script_and_tag_with_icons_input_file_path1_.yml b/tests/test_markdown_mermaid/test_add_mermaid_script_and_tag_with_icons_input_file_path1_.yml new file mode 100644 index 0000000..c03aaeb --- /dev/null +++ b/tests/test_markdown_mermaid/test_add_mermaid_script_and_tag_with_icons_input_file_path1_.yml @@ -0,0 +1,13 @@ +'# Title + + + + + Some text. + + + + + End of the file + + ' diff --git a/uv.lock b/uv.lock index 29bc4e3..9edc049 100644 --- a/uv.lock +++ b/uv.lock @@ -24,7 +24,7 @@ name = "bandit" version = "1.7.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "pyyaml" }, { name = "rich" }, { name = "stevedore" }, @@ -158,7 +158,7 @@ name = "click" version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } wheels = [ @@ -591,7 +591,7 @@ version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "ghp-import" }, { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, { name = "jinja2" },