mkdocs-likec4 1.0.0__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,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: mkdocs-likec4
3
+ Version: 1.0.0
4
+ Summary: LikeC4 for MkDocs
5
+ Author-email: Jonas Häusler <jonas.haeusler@doubleslash.de>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: mkdocs>=1.6
9
+ Requires-Dist: pyjson5>=2.0
10
+
11
+ # mkdocs-likec4
12
+
13
+ MkDocs plugin for embedding [LikeC4](https://likec4.dev/) architecture diagrams.
14
+
15
+ ## Quick Start
16
+
17
+ 1. Ensure `likec4` and `graphviz` are available on the build system.
18
+ 2. Install the `mkdocs-likec4` plugin via `pip`:
19
+ ```shell
20
+ pip install mkdocs-likec4
21
+ ```
22
+ 3.Add the plugin to your `mkdocs.yml`:
23
+ ```yaml
24
+ plugins:
25
+ - mkdocs-likec4
26
+ ```
27
+ 4. Start embedding views in your markdown:
28
+
29
+ ````markdown
30
+ ```likec4-view
31
+ <your-view-id>
32
+ ```
33
+ ````
34
+
35
+ ## Documentation
36
+
37
+ For complete documentation with available options and examples, please read the **[Documentation](https://doubleslashde.github.io/mkdocs-likec4/)**.
38
+
39
+ ## Development
40
+
41
+ ### Registry setup
42
+
43
+ Run `./local-preview` in your terminal to build and run a MkDocs server with the plugin installed,
44
+ serving on <http://127.0.0.1:8000/>.
45
+
46
+ ## Releasing
47
+
48
+ Trigger the manual `release` workflow via GitHub Actions.
@@ -0,0 +1,38 @@
1
+ # mkdocs-likec4
2
+
3
+ MkDocs plugin for embedding [LikeC4](https://likec4.dev/) architecture diagrams.
4
+
5
+ ## Quick Start
6
+
7
+ 1. Ensure `likec4` and `graphviz` are available on the build system.
8
+ 2. Install the `mkdocs-likec4` plugin via `pip`:
9
+ ```shell
10
+ pip install mkdocs-likec4
11
+ ```
12
+ 3.Add the plugin to your `mkdocs.yml`:
13
+ ```yaml
14
+ plugins:
15
+ - mkdocs-likec4
16
+ ```
17
+ 4. Start embedding views in your markdown:
18
+
19
+ ````markdown
20
+ ```likec4-view
21
+ <your-view-id>
22
+ ```
23
+ ````
24
+
25
+ ## Documentation
26
+
27
+ For complete documentation with available options and examples, please read the **[Documentation](https://doubleslashde.github.io/mkdocs-likec4/)**.
28
+
29
+ ## Development
30
+
31
+ ### Registry setup
32
+
33
+ Run `./local-preview` in your terminal to build and run a MkDocs server with the plugin installed,
34
+ serving on <http://127.0.0.1:8000/>.
35
+
36
+ ## Releasing
37
+
38
+ Trigger the manual `release` workflow via GitHub Actions.
File without changes
@@ -0,0 +1,73 @@
1
+ import logging
2
+ import subprocess
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from .parser import LikeC4Parser
7
+
8
+ log = logging.getLogger(f"mkdocs.plugins.{__name__}")
9
+
10
+
11
+ class WebComponentGenerator:
12
+ """Generates LikeC4 web component JavaScript files."""
13
+
14
+ ASSETS_DIR = "assets/mkdocs_likec4"
15
+
16
+ @classmethod
17
+ def get_script_path(cls, project: Optional[str]) -> str:
18
+ """Get the site-relative path for a project's web component JS file."""
19
+ if project is None:
20
+ return f"{cls.ASSETS_DIR}/likec4_views.js"
21
+ return f"{cls.ASSETS_DIR}/likec4_views_{project}.js".lower()
22
+
23
+ @classmethod
24
+ def generate(
25
+ cls,
26
+ project_name: Optional[str],
27
+ project_dir: Optional[str],
28
+ build_dir: str,
29
+ site_dir: Path,
30
+ ) -> None:
31
+ """Generate web component JS file for a LikeC4 project."""
32
+ if project_name is not None and not LikeC4Parser.is_valid_identifier(
33
+ project_name
34
+ ):
35
+ log.error(
36
+ "mkdocs-likec4: Invalid project name '%s': must start with a letter "
37
+ "and contain only letters, numbers, hyphens, and underscores",
38
+ project_name,
39
+ )
40
+ return
41
+
42
+ site_dir.joinpath(cls.ASSETS_DIR).mkdir(parents=True, exist_ok=True)
43
+ dest_file = site_dir.joinpath(cls.get_script_path(project_name))
44
+
45
+ project_path = (
46
+ build_dir
47
+ if project_name is None
48
+ else str(Path(build_dir) / (project_dir or project_name))
49
+ )
50
+ log.info(
51
+ "mkdocs-likec4: Generating web component for %s from %s",
52
+ f"project '{project_name}'" if project_name else "default project",
53
+ project_path,
54
+ )
55
+
56
+ cmd = ["npx", "likec4", "codegen", "webcomponent"]
57
+ if project_name is not None:
58
+ cmd.extend(["--webcomponent-prefix", project_name.lower()])
59
+ cmd.extend([project_path, "-o", str(dest_file)])
60
+
61
+ try:
62
+ subprocess.run(cmd, check=True)
63
+ except subprocess.CalledProcessError as e:
64
+ log.error(
65
+ "mkdocs-likec4: Failed to generate web component for project '%s': %s",
66
+ project_name or "default",
67
+ e,
68
+ )
69
+ except FileNotFoundError:
70
+ log.error(
71
+ "mkdocs-likec4: 'npx' or 'likec4' command not found. "
72
+ "Ensure Node.js and likec4 are installed."
73
+ )
@@ -0,0 +1,80 @@
1
+ import logging
2
+ import re
3
+ from dataclasses import dataclass
4
+ from html import escape
5
+ from typing import Optional
6
+
7
+ log = logging.getLogger(f"mkdocs.plugins.{__name__}")
8
+
9
+ IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*$")
10
+
11
+
12
+ @dataclass
13
+ class ViewOptions:
14
+ """Options parsed from a likec4-view code block."""
15
+
16
+ view_id: str
17
+ browser: str = "true"
18
+ dynamic_variant: str = "diagram"
19
+ project: Optional[str] = None
20
+
21
+
22
+ class LikeC4Parser:
23
+ """Parser for likec4-view markdown code blocks."""
24
+
25
+ PATTERN = re.compile(
26
+ r"```likec4-view([^\r\n]*)\r?\n(.+?)\r?\n```",
27
+ flags=re.DOTALL,
28
+ )
29
+ OPT_BROWSER = re.compile(r"\bbrowser=(true|false)\b")
30
+ OPT_VARIANT = re.compile(r"\bdynamic-variant=(diagram|sequence)\b")
31
+ OPT_PROJECT = re.compile(r"\bproject=([^\s]+)\b")
32
+
33
+ @classmethod
34
+ def is_valid_identifier(cls, value: str) -> bool:
35
+ """Validate that an identifier contains only safe characters."""
36
+ return bool(IDENTIFIER_PATTERN.match(value))
37
+
38
+ @classmethod
39
+ def parse_options(cls, options_text: str, view_id: str) -> ViewOptions:
40
+ """
41
+ Parse options from the opening fence line of a likec4-view block.
42
+
43
+ Options can appear in any order and are all optional with sensible defaults.
44
+ """
45
+ opts = ViewOptions(view_id=view_id)
46
+
47
+ if m := cls.OPT_BROWSER.search(options_text):
48
+ opts.browser = m.group(1)
49
+
50
+ if m := cls.OPT_VARIANT.search(options_text):
51
+ opts.dynamic_variant = m.group(1)
52
+
53
+ if m := cls.OPT_PROJECT.search(options_text):
54
+ opts.project = m.group(1)
55
+
56
+ return opts
57
+
58
+ @classmethod
59
+ def to_html(cls, opts: ViewOptions) -> str:
60
+ if not cls.is_valid_identifier(opts.view_id):
61
+ log.warning(
62
+ "mkdocs-likec4: Invalid view ID '%s': contains unsafe characters",
63
+ opts.view_id,
64
+ )
65
+
66
+ if opts.project and not cls.is_valid_identifier(opts.project):
67
+ log.warning(
68
+ "mkdocs-likec4: Invalid project name '%s': using 'likec4-view' tag",
69
+ opts.project,
70
+ )
71
+
72
+ tag = (
73
+ f"{opts.project.lower()}-view"
74
+ if opts.project and cls.is_valid_identifier(opts.project)
75
+ else "likec4-view"
76
+ )
77
+ return (
78
+ f'<{tag} view-id="{escape(opts.view_id, quote=True)}" '
79
+ f'browser="{opts.browser}" dynamic-variant="{opts.dynamic_variant}"></{tag}>'
80
+ )
@@ -0,0 +1,122 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ import pyjson5
6
+ from mkdocs.plugins import BasePlugin
7
+ from mkdocs.utils import get_relative_url
8
+
9
+ from .generator import WebComponentGenerator
10
+ from .parser import LikeC4Parser
11
+
12
+ log = logging.getLogger(f"mkdocs.plugins.{__name__}")
13
+
14
+
15
+ class LikeC4Plugin(BasePlugin):
16
+ """MkDocs plugin for embedding LikeC4 architecture diagrams."""
17
+
18
+ def __init__(self):
19
+ self.docs_dir = None
20
+ self.page_projects = {}
21
+ self.project_map = {}
22
+
23
+ def _discover_projects(self, docs_dir: Path):
24
+ """Discover LikeC4 projects by scanning for likec4.config.json files."""
25
+ if not docs_dir.exists():
26
+ log.warning("mkdocs-likec4: docs_dir does not exist: %s", docs_dir)
27
+ return
28
+
29
+ for config_file in docs_dir.rglob("likec4.config.json"):
30
+ try:
31
+ with config_file.open("r") as f:
32
+ config_data = pyjson5.load(f)
33
+ if project_name := config_data.get("name"):
34
+ project_dir = str(config_file.parent.relative_to(docs_dir))
35
+ self.project_map[project_name] = project_dir
36
+ log.info(
37
+ "mkdocs-likec4: Discovered project '%s' at %s",
38
+ project_name,
39
+ project_dir,
40
+ )
41
+ except (pyjson5.Json5Exception, OSError) as e:
42
+ log.warning("mkdocs-likec4: Failed to read %s: %s", config_file, e)
43
+
44
+ if not self.project_map:
45
+ self.project_map[None] = "."
46
+ log.info(
47
+ "mkdocs-likec4: No projects discovered, using default root project"
48
+ )
49
+
50
+ def _find_nearest_project(self, page_path: Path, docs_dir: Path) -> Optional[str]:
51
+ """Find the nearest LikeC4 project by traversing upward from the page."""
52
+ current = page_path.parent
53
+ while current >= docs_dir:
54
+ relative_str = str(current.relative_to(docs_dir))
55
+ for project_name, project_dir in self.project_map.items():
56
+ if project_dir == relative_str:
57
+ return project_name
58
+ if current == docs_dir:
59
+ break
60
+ current = current.parent
61
+ return None
62
+
63
+ def on_config(self, config):
64
+ self.docs_dir = Path(config["docs_dir"])
65
+ self._discover_projects(self.docs_dir)
66
+ return config
67
+
68
+ def on_page_markdown(self, markdown: str, page, **kwargs) -> str:
69
+ """Parse likec4-view code blocks and replace with web component HTML."""
70
+ page_file = page.file.src_uri
71
+ projects_on_page = set()
72
+ page_path = self.docs_dir / page.file.src_path
73
+
74
+ def replacer(match):
75
+ options_text = (match.group(1) or "").strip()
76
+ view_id = match.group(2).strip()
77
+ opts = LikeC4Parser.parse_options(options_text, view_id)
78
+
79
+ if opts.project is None:
80
+ opts.project = self._find_nearest_project(page_path, self.docs_dir)
81
+ if opts.project:
82
+ log.debug(
83
+ "mkdocs-likec4: Auto-detected project '%s' for %s",
84
+ opts.project,
85
+ page_file,
86
+ )
87
+
88
+ projects_on_page.add(opts.project)
89
+ return LikeC4Parser.to_html(opts)
90
+
91
+ markdown = LikeC4Parser.PATTERN.sub(replacer, markdown)
92
+ if projects_on_page:
93
+ self.page_projects[page_file] = projects_on_page
94
+ return markdown
95
+
96
+ def on_page_content(self, html, page, **kwargs):
97
+ """Inject project-specific JavaScript only on pages that use likec4-view."""
98
+ page_file = page.file.src_uri
99
+ if page_file not in self.page_projects:
100
+ return html
101
+
102
+ scripts = [
103
+ f'<script src="{get_relative_url(WebComponentGenerator.get_script_path(p), page.url)}"></script>'
104
+ for p in self.page_projects[page_file]
105
+ ]
106
+ return "\n".join(scripts) + "\n" + html
107
+
108
+ def on_post_build(self, config):
109
+ """Generate web component JS files for all projects used across the site."""
110
+ site_dir = Path(config["site_dir"])
111
+ all_projects = {p for projects in self.page_projects.values() for p in projects}
112
+
113
+ for project in all_projects:
114
+ if project in self.project_map:
115
+ WebComponentGenerator.generate(
116
+ project, self.project_map[project], str(self.docs_dir), site_dir
117
+ )
118
+ else:
119
+ log.warning(
120
+ "mkdocs-likec4: Skipping generation for undiscovered project: %s",
121
+ project,
122
+ )
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: mkdocs-likec4
3
+ Version: 1.0.0
4
+ Summary: LikeC4 for MkDocs
5
+ Author-email: Jonas Häusler <jonas.haeusler@doubleslash.de>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: mkdocs>=1.6
9
+ Requires-Dist: pyjson5>=2.0
10
+
11
+ # mkdocs-likec4
12
+
13
+ MkDocs plugin for embedding [LikeC4](https://likec4.dev/) architecture diagrams.
14
+
15
+ ## Quick Start
16
+
17
+ 1. Ensure `likec4` and `graphviz` are available on the build system.
18
+ 2. Install the `mkdocs-likec4` plugin via `pip`:
19
+ ```shell
20
+ pip install mkdocs-likec4
21
+ ```
22
+ 3.Add the plugin to your `mkdocs.yml`:
23
+ ```yaml
24
+ plugins:
25
+ - mkdocs-likec4
26
+ ```
27
+ 4. Start embedding views in your markdown:
28
+
29
+ ````markdown
30
+ ```likec4-view
31
+ <your-view-id>
32
+ ```
33
+ ````
34
+
35
+ ## Documentation
36
+
37
+ For complete documentation with available options and examples, please read the **[Documentation](https://doubleslashde.github.io/mkdocs-likec4/)**.
38
+
39
+ ## Development
40
+
41
+ ### Registry setup
42
+
43
+ Run `./local-preview` in your terminal to build and run a MkDocs server with the plugin installed,
44
+ serving on <http://127.0.0.1:8000/>.
45
+
46
+ ## Releasing
47
+
48
+ Trigger the manual `release` workflow via GitHub Actions.
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ mkdocs_likec4/__init__.py
4
+ mkdocs_likec4/generator.py
5
+ mkdocs_likec4/parser.py
6
+ mkdocs_likec4/plugin.py
7
+ mkdocs_likec4.egg-info/PKG-INFO
8
+ mkdocs_likec4.egg-info/SOURCES.txt
9
+ mkdocs_likec4.egg-info/dependency_links.txt
10
+ mkdocs_likec4.egg-info/entry_points.txt
11
+ mkdocs_likec4.egg-info/requires.txt
12
+ mkdocs_likec4.egg-info/top_level.txt
13
+ tests/test_generator.py
14
+ tests/test_parser.py
15
+ tests/test_plugin.py
@@ -0,0 +1,2 @@
1
+ [mkdocs.plugins]
2
+ likec4 = mkdocs_likec4.plugin:LikeC4Plugin
@@ -0,0 +1,2 @@
1
+ mkdocs>=1.6
2
+ pyjson5>=2.0
@@ -0,0 +1 @@
1
+ mkdocs_likec4
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mkdocs-likec4"
7
+ description = "LikeC4 for MkDocs"
8
+ version = "1.0.0"
9
+ authors = [{ name = "Jonas Häusler", email = "jonas.haeusler@doubleslash.de" }]
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "mkdocs>=1.6",
14
+ "pyjson5>=2.0",
15
+ ]
16
+
17
+ [tool.setuptools.packages.find]
18
+ include = ["mkdocs_likec4*"]
19
+
20
+ [project.entry-points."mkdocs.plugins"]
21
+ "likec4" = "mkdocs_likec4.plugin:LikeC4Plugin"
22
+
23
+ [tool.commitizen]
24
+ name = "cz_customize"
25
+ tag_format = "v$version"
26
+ version_provider = "uv"
27
+ version_files = [
28
+ "pyproject.toml:version"
29
+ ]
30
+
31
+ [tool.commitizen.customize]
32
+ bump_pattern = '^(feat|fix|ci|build|perf|refactor|chore)'
33
+ bump_map = { feat = "MINOR", fix = "PATCH", ci = "PATCH", build = "PATCH", perf = "PATCH", refactor = "PATCH", 'chore' = "PATCH" }
34
+ schema_pattern = '^(build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert|bump|chore)(\(\S+\))?\:?\s.*'
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "commitizen>=3.31.0",
39
+ "ruff>=0.14.11",
40
+ ]
41
+ test = [
42
+ "pytest>=8.0",
43
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,157 @@
1
+ """Tests for the LikeC4 generator module."""
2
+
3
+ import subprocess
4
+ from unittest.mock import patch
5
+
6
+ from mkdocs_likec4.generator import WebComponentGenerator
7
+
8
+
9
+ class TestGetScriptPath:
10
+ """Tests for the get_script_path method."""
11
+
12
+ def test_default_project_path(self):
13
+ """Test script path for default (None) project."""
14
+ path = WebComponentGenerator.get_script_path(None)
15
+ assert path == "assets/mkdocs_likec4/likec4_views.js"
16
+
17
+ def test_named_project_path(self):
18
+ """Test script path for named project."""
19
+ path = WebComponentGenerator.get_script_path("myproject")
20
+ assert path == "assets/mkdocs_likec4/likec4_views_myproject.js"
21
+
22
+ def test_uppercase_project_lowercased(self):
23
+ """Test that uppercase project names are lowercased."""
24
+ path = WebComponentGenerator.get_script_path("MyProject")
25
+ assert path == "assets/mkdocs_likec4/likec4_views_myproject.js"
26
+
27
+ def test_mixed_case_project(self):
28
+ """Test mixed case project name."""
29
+ path = WebComponentGenerator.get_script_path("My-Project_123")
30
+ assert path == "assets/mkdocs_likec4/likec4_views_my-project_123.js"
31
+
32
+
33
+ class TestGenerate:
34
+ """Tests for the generate method."""
35
+
36
+ @patch("mkdocs_likec4.generator.subprocess.run")
37
+ def test_generate_default_project(self, mock_run, tmp_path):
38
+ """Test generating web component for default project."""
39
+ site_dir = tmp_path / "site"
40
+ site_dir.mkdir()
41
+
42
+ WebComponentGenerator.generate(
43
+ project_name=None,
44
+ project_dir=None,
45
+ build_dir="/docs",
46
+ site_dir=site_dir,
47
+ )
48
+
49
+ mock_run.assert_called_once()
50
+ call_args = mock_run.call_args[0][0]
51
+ call_kwargs = mock_run.call_args[1]
52
+ assert call_args[0] == "npx"
53
+ assert call_args[1] == "likec4"
54
+ assert call_args[2] == "codegen"
55
+ assert call_args[3] == "webcomponent"
56
+ assert "/docs" in call_args[4]
57
+ assert "--webcomponent-prefix" not in call_args
58
+ # Verify check=True is passed for proper error handling
59
+ assert call_kwargs.get("check") is True
60
+
61
+ @patch("mkdocs_likec4.generator.subprocess.run")
62
+ def test_generate_named_project(self, mock_run, tmp_path):
63
+ """Test generating web component for named project."""
64
+ site_dir = tmp_path / "site"
65
+ site_dir.mkdir()
66
+
67
+ WebComponentGenerator.generate(
68
+ project_name="myproject",
69
+ project_dir="subdir",
70
+ build_dir="/docs",
71
+ site_dir=site_dir,
72
+ )
73
+
74
+ mock_run.assert_called_once()
75
+ call_args = mock_run.call_args[0][0]
76
+ assert "--webcomponent-prefix" in call_args
77
+ prefix_idx = call_args.index("--webcomponent-prefix")
78
+ assert call_args[prefix_idx + 1] == "myproject"
79
+
80
+ @patch("mkdocs_likec4.generator.subprocess.run")
81
+ def test_generate_creates_assets_dir(self, mock_run, tmp_path):
82
+ """Test that generate creates the assets directory."""
83
+ site_dir = tmp_path / "site"
84
+ site_dir.mkdir()
85
+
86
+ WebComponentGenerator.generate(
87
+ project_name=None,
88
+ project_dir=None,
89
+ build_dir="/docs",
90
+ site_dir=site_dir,
91
+ )
92
+
93
+ assets_dir = site_dir / "assets" / "mkdocs_likec4"
94
+ assert assets_dir.exists()
95
+
96
+ @patch("mkdocs_likec4.generator.subprocess.run")
97
+ def test_generate_invalid_project_name_skipped(self, mock_run, tmp_path):
98
+ """Test that invalid project names are skipped."""
99
+ site_dir = tmp_path / "site"
100
+ site_dir.mkdir()
101
+
102
+ WebComponentGenerator.generate(
103
+ project_name="123invalid",
104
+ project_dir=None,
105
+ build_dir="/docs",
106
+ site_dir=site_dir,
107
+ )
108
+
109
+ mock_run.assert_not_called()
110
+
111
+ @patch("mkdocs_likec4.generator.subprocess.run")
112
+ def test_generate_handles_subprocess_error(self, mock_run, tmp_path):
113
+ """Test that subprocess errors are handled gracefully."""
114
+ site_dir = tmp_path / "site"
115
+ site_dir.mkdir()
116
+ mock_run.side_effect = subprocess.CalledProcessError(1, "cmd")
117
+
118
+ # Should not raise
119
+ WebComponentGenerator.generate(
120
+ project_name=None,
121
+ project_dir=None,
122
+ build_dir="/docs",
123
+ site_dir=site_dir,
124
+ )
125
+
126
+ @patch("mkdocs_likec4.generator.subprocess.run")
127
+ def test_generate_handles_file_not_found(self, mock_run, tmp_path):
128
+ """Test that FileNotFoundError is handled gracefully."""
129
+ site_dir = tmp_path / "site"
130
+ site_dir.mkdir()
131
+ mock_run.side_effect = FileNotFoundError("npx not found")
132
+
133
+ # Should not raise
134
+ WebComponentGenerator.generate(
135
+ project_name=None,
136
+ project_dir=None,
137
+ build_dir="/docs",
138
+ site_dir=site_dir,
139
+ )
140
+
141
+ @patch("mkdocs_likec4.generator.subprocess.run")
142
+ def test_generate_output_path(self, mock_run, tmp_path):
143
+ """Test that output path is correct."""
144
+ site_dir = tmp_path / "site"
145
+ site_dir.mkdir()
146
+
147
+ WebComponentGenerator.generate(
148
+ project_name="proj",
149
+ project_dir="projdir",
150
+ build_dir="/docs",
151
+ site_dir=site_dir,
152
+ )
153
+
154
+ call_args = mock_run.call_args[0][0]
155
+ output_idx = call_args.index("-o")
156
+ output_path = call_args[output_idx + 1]
157
+ assert "likec4_views_proj.js" in output_path
@@ -0,0 +1,266 @@
1
+ """Tests for the LikeC4 parser module."""
2
+
3
+ from mkdocs_likec4.parser import LikeC4Parser, ViewOptions
4
+
5
+
6
+ class TestViewOptions:
7
+ """Tests for the ViewOptions dataclass."""
8
+
9
+ def test_default_values(self):
10
+ """Test that ViewOptions has correct default values."""
11
+ opts = ViewOptions(view_id="test-view")
12
+ assert opts.view_id == "test-view"
13
+ assert opts.browser == "true"
14
+ assert opts.dynamic_variant == "diagram"
15
+ assert opts.project is None
16
+
17
+ def test_custom_values(self):
18
+ """Test ViewOptions with custom values."""
19
+ opts = ViewOptions(
20
+ view_id="my-view",
21
+ browser="false",
22
+ dynamic_variant="sequence",
23
+ project="myproject",
24
+ )
25
+ assert opts.view_id == "my-view"
26
+ assert opts.browser == "false"
27
+ assert opts.dynamic_variant == "sequence"
28
+ assert opts.project == "myproject"
29
+
30
+
31
+ class TestLikeC4ParserPattern:
32
+ """Tests for the regex pattern matching."""
33
+
34
+ def test_basic_code_block(self):
35
+ """Test matching a basic likec4-view code block."""
36
+ markdown = """```likec4-view
37
+ my-view-id
38
+ ```"""
39
+ match = LikeC4Parser.PATTERN.search(markdown)
40
+ assert match is not None
41
+ assert match.group(1) == ""
42
+ assert match.group(2) == "my-view-id"
43
+
44
+ def test_code_block_with_options(self):
45
+ """Test matching a code block with options."""
46
+ markdown = """```likec4-view browser=false project=myproj
47
+ view-id-here
48
+ ```"""
49
+ match = LikeC4Parser.PATTERN.search(markdown)
50
+ assert match is not None
51
+ assert "browser=false" in match.group(1)
52
+ assert "project=myproj" in match.group(1)
53
+ assert match.group(2) == "view-id-here"
54
+
55
+ def test_no_match_for_other_code_blocks(self):
56
+ """Test that other code blocks don't match."""
57
+ markdown = """```python
58
+ print("hello")
59
+ ```"""
60
+ match = LikeC4Parser.PATTERN.search(markdown)
61
+ assert match is None
62
+
63
+ def test_multiple_code_blocks(self):
64
+ """Test finding multiple code blocks."""
65
+ markdown = """```likec4-view
66
+ view1
67
+ ```
68
+
69
+ Some text
70
+
71
+ ```likec4-view project=proj2
72
+ view2
73
+ ```"""
74
+ matches = list(LikeC4Parser.PATTERN.finditer(markdown))
75
+ assert len(matches) == 2
76
+ assert matches[0].group(2) == "view1"
77
+ assert matches[1].group(2) == "view2"
78
+
79
+
80
+ class TestParseOptions:
81
+ """Tests for the parse_options method."""
82
+
83
+ def test_empty_options(self):
84
+ """Test parsing empty options string."""
85
+ opts = LikeC4Parser.parse_options("", "test-view")
86
+ assert opts.view_id == "test-view"
87
+ assert opts.browser == "true"
88
+ assert opts.dynamic_variant == "diagram"
89
+ assert opts.project is None
90
+
91
+ def test_browser_option_true(self):
92
+ """Test parsing browser=true option."""
93
+ opts = LikeC4Parser.parse_options("browser=true", "view")
94
+ assert opts.browser == "true"
95
+
96
+ def test_browser_option_false(self):
97
+ """Test parsing browser=false option."""
98
+ opts = LikeC4Parser.parse_options("browser=false", "view")
99
+ assert opts.browser == "false"
100
+
101
+ def test_dynamic_variant_diagram(self):
102
+ """Test parsing dynamic-variant=diagram option."""
103
+ opts = LikeC4Parser.parse_options("dynamic-variant=diagram", "view")
104
+ assert opts.dynamic_variant == "diagram"
105
+
106
+ def test_dynamic_variant_sequence(self):
107
+ """Test parsing dynamic-variant=sequence option."""
108
+ opts = LikeC4Parser.parse_options("dynamic-variant=sequence", "view")
109
+ assert opts.dynamic_variant == "sequence"
110
+
111
+ def test_project_option(self):
112
+ """Test parsing project option."""
113
+ opts = LikeC4Parser.parse_options("project=myproject", "view")
114
+ assert opts.project == "myproject"
115
+
116
+ def test_multiple_options(self):
117
+ """Test parsing multiple options."""
118
+ opts = LikeC4Parser.parse_options(
119
+ "browser=false dynamic-variant=sequence project=proj1", "view"
120
+ )
121
+ assert opts.browser == "false"
122
+ assert opts.dynamic_variant == "sequence"
123
+ assert opts.project == "proj1"
124
+
125
+ def test_options_in_any_order(self):
126
+ """Test that options can appear in any order."""
127
+ opts = LikeC4Parser.parse_options(
128
+ "project=proj browser=false dynamic-variant=sequence", "view"
129
+ )
130
+ assert opts.browser == "false"
131
+ assert opts.dynamic_variant == "sequence"
132
+ assert opts.project == "proj"
133
+
134
+ def test_invalid_browser_value_ignored(self):
135
+ """Test that invalid browser value is ignored."""
136
+ opts = LikeC4Parser.parse_options("browser=invalid", "view")
137
+ assert opts.browser == "true" # default
138
+
139
+ def test_invalid_variant_value_ignored(self):
140
+ """Test that invalid dynamic-variant value is ignored."""
141
+ opts = LikeC4Parser.parse_options("dynamic-variant=invalid", "view")
142
+ assert opts.dynamic_variant == "diagram" # default
143
+
144
+
145
+ class TestIsValidIdentifier:
146
+ """Tests for the is_valid_identifier method."""
147
+
148
+ def test_valid_simple_name(self):
149
+ """Test valid simple identifier."""
150
+ assert LikeC4Parser.is_valid_identifier("project") is True
151
+
152
+ def test_valid_name_with_numbers(self):
153
+ """Test valid identifier with numbers."""
154
+ assert LikeC4Parser.is_valid_identifier("project123") is True
155
+
156
+ def test_valid_name_with_hyphen(self):
157
+ """Test valid identifier with hyphen."""
158
+ assert LikeC4Parser.is_valid_identifier("my-project") is True
159
+
160
+ def test_valid_name_with_underscore(self):
161
+ """Test valid identifier with underscore."""
162
+ assert LikeC4Parser.is_valid_identifier("my_project") is True
163
+
164
+ def test_valid_mixed_name(self):
165
+ """Test valid identifier with mixed characters."""
166
+ assert LikeC4Parser.is_valid_identifier("My-Project_123") is True
167
+
168
+ def test_invalid_starts_with_number(self):
169
+ """Test invalid identifier starting with number."""
170
+ assert LikeC4Parser.is_valid_identifier("123project") is False
171
+
172
+ def test_invalid_starts_with_hyphen(self):
173
+ """Test invalid identifier starting with hyphen."""
174
+ assert LikeC4Parser.is_valid_identifier("-project") is False
175
+
176
+ def test_invalid_contains_space(self):
177
+ """Test invalid identifier with space."""
178
+ assert LikeC4Parser.is_valid_identifier("my project") is False
179
+
180
+ def test_invalid_contains_special_chars(self):
181
+ """Test invalid identifier with special characters."""
182
+ assert LikeC4Parser.is_valid_identifier("project@name") is False
183
+ assert LikeC4Parser.is_valid_identifier("project.name") is False
184
+ assert LikeC4Parser.is_valid_identifier("project/name") is False
185
+
186
+ def test_invalid_empty_string(self):
187
+ """Test invalid empty identifier."""
188
+ assert LikeC4Parser.is_valid_identifier("") is False
189
+
190
+ def test_invalid_with_quotes(self):
191
+ """Test invalid identifier with quotes (XSS attempt)."""
192
+ assert LikeC4Parser.is_valid_identifier('view" onclick="alert(1)') is False
193
+
194
+ def test_invalid_with_angle_brackets(self):
195
+ """Test invalid identifier with angle brackets."""
196
+ assert LikeC4Parser.is_valid_identifier("view<script>") is False
197
+
198
+
199
+ class TestToHtml:
200
+ """Tests for the to_html method."""
201
+
202
+ def test_basic_html_output(self):
203
+ """Test basic HTML output without project."""
204
+ opts = ViewOptions(view_id="my-view")
205
+ html = LikeC4Parser.to_html(opts)
206
+ assert (
207
+ html
208
+ == '<likec4-view view-id="my-view" browser="true" dynamic-variant="diagram"></likec4-view>'
209
+ )
210
+
211
+ def test_html_with_project(self):
212
+ """Test HTML output with valid project."""
213
+ opts = ViewOptions(view_id="my-view", project="myproject")
214
+ html = LikeC4Parser.to_html(opts)
215
+ assert (
216
+ html
217
+ == '<myproject-view view-id="my-view" browser="true" dynamic-variant="diagram"></myproject-view>'
218
+ )
219
+
220
+ def test_html_with_uppercase_project(self):
221
+ """Test HTML output with uppercase project (should be lowercased)."""
222
+ opts = ViewOptions(view_id="my-view", project="MyProject")
223
+ html = LikeC4Parser.to_html(opts)
224
+ assert (
225
+ html
226
+ == '<myproject-view view-id="my-view" browser="true" dynamic-variant="diagram"></myproject-view>'
227
+ )
228
+
229
+ def test_html_with_custom_options(self):
230
+ """Test HTML output with custom options."""
231
+ opts = ViewOptions(
232
+ view_id="test",
233
+ browser="false",
234
+ dynamic_variant="sequence",
235
+ project="proj",
236
+ )
237
+ html = LikeC4Parser.to_html(opts)
238
+ assert (
239
+ html
240
+ == '<proj-view view-id="test" browser="false" dynamic-variant="sequence"></proj-view>'
241
+ )
242
+
243
+ def test_html_with_invalid_project_falls_back(self):
244
+ """Test HTML output with invalid project name falls back to likec4-view."""
245
+ opts = ViewOptions(view_id="my-view", project="123invalid")
246
+ html = LikeC4Parser.to_html(opts)
247
+ assert (
248
+ html
249
+ == '<likec4-view view-id="my-view" browser="true" dynamic-variant="diagram"></likec4-view>'
250
+ )
251
+
252
+ def test_html_escapes_invalid_view_id_with_quotes(self):
253
+ """Test that invalid view ID with quotes is escaped to prevent XSS."""
254
+ opts = ViewOptions(view_id='view" onclick="alert(1)')
255
+ html = LikeC4Parser.to_html(opts)
256
+ # Should escape quotes
257
+ assert "onclick" not in html or "&quot;" in html
258
+ assert '"alert' not in html
259
+
260
+ def test_html_escapes_invalid_view_id_with_angle_brackets(self):
261
+ """Test that invalid view ID with angle brackets is escaped."""
262
+ opts = ViewOptions(view_id="view<script>alert(1)</script>")
263
+ html = LikeC4Parser.to_html(opts)
264
+ # Should escape angle brackets
265
+ assert "<script>" not in html
266
+ assert "&lt;" in html or "script" not in html
@@ -0,0 +1,358 @@
1
+ """Tests for the LikeC4 plugin module."""
2
+
3
+ import json
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from mkdocs_likec4.plugin import LikeC4Plugin
9
+
10
+
11
+ @pytest.fixture
12
+ def plugin():
13
+ """Create a fresh plugin instance."""
14
+ return LikeC4Plugin()
15
+
16
+
17
+ @pytest.fixture
18
+ def docs_dir(tmp_path):
19
+ """Create a temporary docs directory."""
20
+ docs = tmp_path / "docs"
21
+ docs.mkdir()
22
+ return docs
23
+
24
+
25
+ class TestPluginInit:
26
+ """Tests for plugin initialization."""
27
+
28
+ def test_init_defaults(self, plugin):
29
+ """Test that plugin initializes with correct defaults."""
30
+ assert plugin.docs_dir is None
31
+ assert plugin.page_projects == {}
32
+ assert plugin.project_map == {}
33
+
34
+
35
+ class TestDiscoverProjects:
36
+ """Tests for the _discover_projects method."""
37
+
38
+ def test_discover_single_project(self, plugin, docs_dir):
39
+ """Test discovering a single project."""
40
+ project_dir = docs_dir / "myproject"
41
+ project_dir.mkdir()
42
+ config_file = project_dir / "likec4.config.json"
43
+ config_file.write_text(json.dumps({"name": "myproject"}))
44
+
45
+ plugin._discover_projects(docs_dir)
46
+
47
+ assert "myproject" in plugin.project_map
48
+ assert plugin.project_map["myproject"] == "myproject"
49
+
50
+ def test_discover_multiple_projects(self, plugin, docs_dir):
51
+ """Test discovering multiple projects."""
52
+ for name in ["project1", "project2", "project3"]:
53
+ project_dir = docs_dir / name
54
+ project_dir.mkdir()
55
+ config_file = project_dir / "likec4.config.json"
56
+ config_file.write_text(json.dumps({"name": name}))
57
+
58
+ plugin._discover_projects(docs_dir)
59
+
60
+ assert len(plugin.project_map) == 3
61
+ assert "project1" in plugin.project_map
62
+ assert "project2" in plugin.project_map
63
+ assert "project3" in plugin.project_map
64
+
65
+ def test_discover_nested_project(self, plugin, docs_dir):
66
+ """Test discovering a nested project."""
67
+ nested_dir = docs_dir / "sub" / "nested"
68
+ nested_dir.mkdir(parents=True)
69
+ config_file = nested_dir / "likec4.config.json"
70
+ config_file.write_text(json.dumps({"name": "nested"}))
71
+
72
+ plugin._discover_projects(docs_dir)
73
+
74
+ assert "nested" in plugin.project_map
75
+ assert plugin.project_map["nested"] == "sub/nested"
76
+
77
+ def test_discover_no_projects_uses_default(self, plugin, docs_dir):
78
+ """Test that no projects defaults to root project."""
79
+ plugin._discover_projects(docs_dir)
80
+
81
+ assert None in plugin.project_map
82
+ assert plugin.project_map[None] == "."
83
+
84
+ def test_discover_invalid_json_skipped(self, plugin, docs_dir):
85
+ """Test that invalid JSON config files are skipped."""
86
+ project_dir = docs_dir / "badproject"
87
+ project_dir.mkdir()
88
+ config_file = project_dir / "likec4.config.json"
89
+ config_file.write_text("not valid json")
90
+
91
+ plugin._discover_projects(docs_dir)
92
+
93
+ assert "badproject" not in plugin.project_map
94
+ # Should fall back to default
95
+ assert None in plugin.project_map
96
+
97
+ def test_discover_config_without_name_skipped(self, plugin, docs_dir):
98
+ """Test that config without name field is skipped."""
99
+ project_dir = docs_dir / "noname"
100
+ project_dir.mkdir()
101
+ config_file = project_dir / "likec4.config.json"
102
+ config_file.write_text(json.dumps({"version": "1.0"}))
103
+
104
+ plugin._discover_projects(docs_dir)
105
+
106
+ assert "noname" not in plugin.project_map
107
+
108
+ def test_discover_nonexistent_dir(self, plugin, tmp_path):
109
+ """Test discovering projects in nonexistent directory."""
110
+ nonexistent = tmp_path / "nonexistent"
111
+
112
+ plugin._discover_projects(nonexistent)
113
+
114
+ # Should not raise, project_map remains empty
115
+ assert plugin.project_map == {}
116
+
117
+
118
+ class TestFindNearestProject:
119
+ """Tests for the _find_nearest_project method."""
120
+
121
+ def test_find_project_in_same_directory(self, plugin, docs_dir):
122
+ """Test finding project when page is in project directory."""
123
+ plugin.project_map = {"myproject": "myproject"}
124
+
125
+ page_path = docs_dir / "myproject" / "index.md"
126
+ result = plugin._find_nearest_project(page_path, docs_dir)
127
+
128
+ assert result == "myproject"
129
+
130
+ def test_find_project_in_subdirectory(self, plugin, docs_dir):
131
+ """Test finding project when page is in subdirectory of project."""
132
+ plugin.project_map = {"myproject": "myproject"}
133
+
134
+ page_path = docs_dir / "myproject" / "sub" / "page.md"
135
+ result = plugin._find_nearest_project(page_path, docs_dir)
136
+
137
+ assert result == "myproject"
138
+
139
+ def test_find_no_project_returns_none(self, plugin, docs_dir):
140
+ """Test that pages outside projects return None."""
141
+ plugin.project_map = {"myproject": "myproject"}
142
+
143
+ page_path = docs_dir / "other" / "page.md"
144
+ result = plugin._find_nearest_project(page_path, docs_dir)
145
+
146
+ assert result is None
147
+
148
+ def test_find_root_project(self, plugin, docs_dir):
149
+ """Test finding root project."""
150
+ plugin.project_map = {None: "."}
151
+
152
+ page_path = docs_dir / "page.md"
153
+ result = plugin._find_nearest_project(page_path, docs_dir)
154
+
155
+ assert result is None
156
+
157
+
158
+ class TestOnConfig:
159
+ """Tests for the on_config method."""
160
+
161
+ def test_on_config_sets_docs_dir(self, plugin, docs_dir):
162
+ """Test that on_config sets docs_dir."""
163
+ config = {"docs_dir": str(docs_dir)}
164
+
165
+ result = plugin.on_config(config)
166
+
167
+ assert plugin.docs_dir == docs_dir
168
+ assert result == config
169
+
170
+ def test_on_config_discovers_projects(self, plugin, docs_dir):
171
+ """Test that on_config triggers project discovery."""
172
+ project_dir = docs_dir / "proj"
173
+ project_dir.mkdir()
174
+ config_file = project_dir / "likec4.config.json"
175
+ config_file.write_text(json.dumps({"name": "proj"}))
176
+
177
+ config = {"docs_dir": str(docs_dir)}
178
+ plugin.on_config(config)
179
+
180
+ assert "proj" in plugin.project_map
181
+
182
+
183
+ class TestOnPageMarkdown:
184
+ """Tests for the on_page_markdown method."""
185
+
186
+ def test_replaces_code_block(self, plugin, docs_dir):
187
+ """Test that likec4-view code blocks are replaced."""
188
+ plugin.docs_dir = docs_dir
189
+ plugin.project_map = {None: "."}
190
+
191
+ page = MagicMock()
192
+ page.file.src_uri = "index.md"
193
+ page.file.src_path = "index.md"
194
+
195
+ markdown = """# Title
196
+
197
+ ```likec4-view
198
+ my-view
199
+ ```
200
+
201
+ Some text."""
202
+
203
+ result = plugin.on_page_markdown(markdown, page)
204
+
205
+ assert "```likec4-view" not in result
206
+ assert '<likec4-view view-id="my-view"' in result
207
+ assert "Some text." in result
208
+
209
+ def test_replaces_multiple_code_blocks(self, plugin, docs_dir):
210
+ """Test that multiple code blocks are replaced."""
211
+ plugin.docs_dir = docs_dir
212
+ plugin.project_map = {None: "."}
213
+
214
+ page = MagicMock()
215
+ page.file.src_uri = "index.md"
216
+ page.file.src_path = "index.md"
217
+
218
+ markdown = """```likec4-view
219
+ view1
220
+ ```
221
+
222
+ ```likec4-view
223
+ view2
224
+ ```"""
225
+
226
+ result = plugin.on_page_markdown(markdown, page)
227
+
228
+ assert 'view-id="view1"' in result
229
+ assert 'view-id="view2"' in result
230
+
231
+ def test_tracks_projects_on_page(self, plugin, docs_dir):
232
+ """Test that projects used on page are tracked."""
233
+ plugin.docs_dir = docs_dir
234
+ plugin.project_map = {"proj": "proj"}
235
+
236
+ page = MagicMock()
237
+ page.file.src_uri = "proj/index.md"
238
+ page.file.src_path = "proj/index.md"
239
+
240
+ markdown = """```likec4-view
241
+ my-view
242
+ ```"""
243
+
244
+ plugin.on_page_markdown(markdown, page)
245
+
246
+ assert "proj/index.md" in plugin.page_projects
247
+ assert "proj" in plugin.page_projects["proj/index.md"]
248
+
249
+ def test_preserves_non_likec4_content(self, plugin, docs_dir):
250
+ """Test that non-likec4 content is preserved."""
251
+ plugin.docs_dir = docs_dir
252
+ plugin.project_map = {None: "."}
253
+
254
+ page = MagicMock()
255
+ page.file.src_uri = "index.md"
256
+ page.file.src_path = "index.md"
257
+
258
+ markdown = """# Title
259
+
260
+ ```python
261
+ print("hello")
262
+ ```
263
+
264
+ Some text."""
265
+
266
+ result = plugin.on_page_markdown(markdown, page)
267
+
268
+ assert result == markdown
269
+
270
+
271
+ class TestOnPageContent:
272
+ """Tests for the on_page_content method."""
273
+
274
+ def test_injects_script_for_page_with_views(self, plugin):
275
+ """Test that script tags are injected for pages with views."""
276
+ plugin.page_projects = {"index.md": {None}}
277
+
278
+ page = MagicMock()
279
+ page.file.src_uri = "index.md"
280
+ page.url = "index.html"
281
+
282
+ html = "<h1>Title</h1>"
283
+ result = plugin.on_page_content(html, page)
284
+
285
+ assert "<script" in result
286
+ assert "likec4_views.js" in result
287
+ assert "<h1>Title</h1>" in result
288
+
289
+ def test_no_script_for_page_without_views(self, plugin):
290
+ """Test that no script is injected for pages without views."""
291
+ plugin.page_projects = {}
292
+
293
+ page = MagicMock()
294
+ page.file.src_uri = "other.md"
295
+ page.url = "other.html"
296
+
297
+ html = "<h1>Title</h1>"
298
+ result = plugin.on_page_content(html, page)
299
+
300
+ assert result == html
301
+
302
+ def test_injects_multiple_scripts_for_multiple_projects(self, plugin):
303
+ """Test that multiple scripts are injected for multiple projects."""
304
+ plugin.page_projects = {"index.md": {"proj1", "proj2"}}
305
+
306
+ page = MagicMock()
307
+ page.file.src_uri = "index.md"
308
+ page.url = "index.html"
309
+
310
+ html = "<h1>Title</h1>"
311
+ result = plugin.on_page_content(html, page)
312
+
313
+ assert "likec4_views_proj1.js" in result
314
+ assert "likec4_views_proj2.js" in result
315
+
316
+
317
+ class TestOnPostBuild:
318
+ """Tests for the on_post_build method."""
319
+
320
+ @patch("mkdocs_likec4.plugin.WebComponentGenerator.generate")
321
+ def test_generates_for_all_used_projects(self, mock_generate, plugin, tmp_path):
322
+ """Test that web components are generated for all used projects."""
323
+ plugin.docs_dir = tmp_path / "docs"
324
+ plugin.docs_dir.mkdir()
325
+ plugin.project_map = {"proj1": "proj1", "proj2": "proj2"}
326
+ plugin.page_projects = {
327
+ "page1.md": {"proj1"},
328
+ "page2.md": {"proj2"},
329
+ }
330
+
331
+ site_dir = tmp_path / "site"
332
+ site_dir.mkdir()
333
+ config = {"site_dir": str(site_dir)}
334
+
335
+ plugin.on_post_build(config)
336
+
337
+ assert mock_generate.call_count == 2
338
+
339
+ @patch("mkdocs_likec4.plugin.WebComponentGenerator.generate")
340
+ def test_skips_undiscovered_projects(self, mock_generate, plugin, tmp_path):
341
+ """Test that undiscovered projects are skipped."""
342
+ plugin.docs_dir = tmp_path / "docs"
343
+ plugin.docs_dir.mkdir()
344
+ plugin.project_map = {"proj1": "proj1"}
345
+ plugin.page_projects = {
346
+ "page1.md": {"proj1", "unknown"},
347
+ }
348
+
349
+ site_dir = tmp_path / "site"
350
+ site_dir.mkdir()
351
+ config = {"site_dir": str(site_dir)}
352
+
353
+ plugin.on_post_build(config)
354
+
355
+ # Only proj1 should be generated
356
+ assert mock_generate.call_count == 1
357
+ call_args = mock_generate.call_args[0]
358
+ assert call_args[0] == "proj1"