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.
- mkdocs_likec4-1.0.0/PKG-INFO +48 -0
- mkdocs_likec4-1.0.0/README.md +38 -0
- mkdocs_likec4-1.0.0/mkdocs_likec4/__init__.py +0 -0
- mkdocs_likec4-1.0.0/mkdocs_likec4/generator.py +73 -0
- mkdocs_likec4-1.0.0/mkdocs_likec4/parser.py +80 -0
- mkdocs_likec4-1.0.0/mkdocs_likec4/plugin.py +122 -0
- mkdocs_likec4-1.0.0/mkdocs_likec4.egg-info/PKG-INFO +48 -0
- mkdocs_likec4-1.0.0/mkdocs_likec4.egg-info/SOURCES.txt +15 -0
- mkdocs_likec4-1.0.0/mkdocs_likec4.egg-info/dependency_links.txt +1 -0
- mkdocs_likec4-1.0.0/mkdocs_likec4.egg-info/entry_points.txt +2 -0
- mkdocs_likec4-1.0.0/mkdocs_likec4.egg-info/requires.txt +2 -0
- mkdocs_likec4-1.0.0/mkdocs_likec4.egg-info/top_level.txt +1 -0
- mkdocs_likec4-1.0.0/pyproject.toml +43 -0
- mkdocs_likec4-1.0.0/setup.cfg +4 -0
- mkdocs_likec4-1.0.0/tests/test_generator.py +157 -0
- mkdocs_likec4-1.0.0/tests/test_parser.py +266 -0
- mkdocs_likec4-1.0.0/tests/test_plugin.py +358 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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 """ 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 "<" 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"
|