ai-doc-creator 2.2.0__py3-none-any.whl

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,3 @@
1
+ """AI Document Creator — AI-powered documentation generator (MCP server + CLI)."""
2
+
3
+ __version__ = "2.2.0"
ai_doc_creator/cli.py ADDED
@@ -0,0 +1,100 @@
1
+ # ai_doc_creator/cli.py
2
+ import argparse
3
+ import asyncio
4
+ import logging
5
+ import sys
6
+
7
+ from dotenv import load_dotenv
8
+
9
+ load_dotenv()
10
+
11
+ from .core.config import resolve_config # noqa: E402
12
+ from .core.backends import pick_backend, BackendError # noqa: E402
13
+ from .core.sources import GitSource, LocalSource # noqa: E402
14
+ from .core.file_traverser import FileTraverser # noqa: E402
15
+ from .core.graph import app # noqa: E402
16
+ from .core.doc_writer import DocumentationWriter # noqa: E402
17
+
18
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ async def run(source, output_dir, config):
23
+ repo_path = None
24
+ try:
25
+ repo_path = source.prepare()
26
+ files = list(FileTraverser(repo_path, max_file_size_kb=config.max_file_size_kb).traverse())
27
+ logger.info("Found %d files to process.", len(files))
28
+ if not files:
29
+ logger.warning("No files found to document. Exiting.")
30
+ return
31
+
32
+ backend = pick_backend(config, ctx=None) # CLI has no host -> requires a provider key
33
+ final_state = await app.ainvoke(
34
+ {
35
+ "repo_path": repo_path,
36
+ "files": files,
37
+ "documents": {},
38
+ "index_content": "",
39
+ "backend": backend,
40
+ "max_concurrency": config.max_concurrency,
41
+ "profile": config.profile,
42
+ "diagrams": config.diagrams,
43
+ }
44
+ )
45
+
46
+ DocumentationWriter(output_dir).write_docs(
47
+ final_state["documents"], final_state["index_content"]
48
+ )
49
+ logger.info("Documentation generation complete!")
50
+ finally:
51
+ if source:
52
+ source.cleanup()
53
+
54
+
55
+ def main():
56
+ parser = argparse.ArgumentParser(description="AI Document Creator")
57
+ group = parser.add_mutually_exclusive_group(required=True)
58
+ group.add_argument("--repo", help="GitHub repository URL")
59
+ group.add_argument("--path", help="Local project directory")
60
+ parser.add_argument("--output", default="docs", help="Output directory")
61
+ parser.add_argument(
62
+ "--provider", default=None, help="LLM provider (anthropic/openai/azure/bedrock/ollama)"
63
+ )
64
+ parser.add_argument("--model", default=None, help="Model name override")
65
+ parser.add_argument(
66
+ "--profile",
67
+ default="readme",
68
+ choices=["readme", "api", "architecture", "tutorial"],
69
+ help="Documentation style (default: readme)",
70
+ )
71
+ parser.add_argument(
72
+ "--no-diagrams",
73
+ action="store_true",
74
+ help="Disable Mermaid architecture diagrams and flow charts",
75
+ )
76
+ args = parser.parse_args()
77
+
78
+ config = resolve_config(
79
+ provider=args.provider,
80
+ model=args.model,
81
+ profile=args.profile,
82
+ diagrams=not args.no_diagrams,
83
+ )
84
+ source = GitSource(args.repo) if args.repo else LocalSource(args.path)
85
+
86
+ try:
87
+ asyncio.run(run(source, args.output, config))
88
+ except BackendError as exc:
89
+ logger.error("%s", exc)
90
+ sys.exit(1)
91
+ except Exception as exc:
92
+ logger.error("An error occurred: %s", exc)
93
+ import traceback
94
+
95
+ traceback.print_exc()
96
+ sys.exit(1)
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()
@@ -0,0 +1 @@
1
+ """Core pipeline: config, backends, sources, guards, graph, cache, writer."""
@@ -0,0 +1,126 @@
1
+ # core/backends.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from abc import ABC, abstractmethod
6
+
7
+ from langchain.chat_models import init_chat_model # imported at top so tests can monkeypatch it
8
+
9
+ from .config import DocConfig
10
+
11
+
12
+ class BackendError(RuntimeError):
13
+ """Raised when no usable LLM backend can be constructed."""
14
+
15
+
16
+ def _content_to_text(content) -> str:
17
+ """Flatten a chat model response (str, or list of content blocks) to plain text."""
18
+ if isinstance(content, str):
19
+ return content
20
+ if isinstance(content, list):
21
+ parts: list[str] = []
22
+ for block in content:
23
+ if isinstance(block, str):
24
+ parts.append(block)
25
+ elif isinstance(block, dict) and block.get("type") == "text":
26
+ parts.append(block.get("text", ""))
27
+ return "".join(parts)
28
+ return str(content)
29
+
30
+
31
+ class CompletionBackend(ABC):
32
+ """Generates text from a prompt. The only thing the pipeline knows about an LLM."""
33
+
34
+ @abstractmethod
35
+ async def complete(self, prompt: str) -> str: ...
36
+
37
+
38
+ class FakeBackend(CompletionBackend):
39
+ """Deterministic backend for tests — never calls a real model."""
40
+
41
+ def __init__(self, response: str = "FAKE DOC"):
42
+ self.response = response
43
+ self.calls: list[str] = []
44
+
45
+ async def complete(self, prompt: str) -> str:
46
+ self.calls.append(prompt)
47
+ return self.response
48
+
49
+
50
+ class ProviderBackend(CompletionBackend):
51
+ """Calls a real provider (anthropic/openai/azure/bedrock/ollama) via LangChain."""
52
+
53
+ def __init__(self, config: DocConfig):
54
+ if not config.has_provider:
55
+ raise BackendError("ProviderBackend requires a provider")
56
+ if config.provider == "azure":
57
+ self._model = self._build_azure(config)
58
+ else:
59
+ # Pass model + provider separately so model names containing ':'
60
+ # (e.g. bedrock 'amazon.nova-pro-v1:0') are never mis-parsed.
61
+ # An explicit per-request key (BYOK) wins over any env credential.
62
+ extra: dict = {}
63
+ if config.api_key:
64
+ extra["api_key"] = config.api_key
65
+ self._model = init_chat_model(
66
+ model=config.resolved_model,
67
+ model_provider=config.lc_provider,
68
+ temperature=0,
69
+ **extra,
70
+ )
71
+
72
+ @staticmethod
73
+ def _build_azure(config: DocConfig):
74
+ from langchain_openai import AzureChatOpenAI
75
+
76
+ from pydantic import SecretStr
77
+
78
+ raw_key = config.api_key or os.getenv("AZURE_OPENAI_API_KEY")
79
+ return AzureChatOpenAI(
80
+ azure_deployment=config.model or os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o"),
81
+ api_version=os.getenv("AZURE_OPENAI_API_VERSION", "2024-12-01-preview"),
82
+ azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
83
+ api_key=SecretStr(raw_key) if raw_key else None,
84
+ temperature=0,
85
+ )
86
+
87
+ async def complete(self, prompt: str) -> str:
88
+ result = await self._model.ainvoke(prompt)
89
+ return _content_to_text(getattr(result, "content", result))
90
+
91
+
92
+ class SamplingBackend(CompletionBackend):
93
+ """Asks the MCP host's own model to generate — zero API cost to the operator."""
94
+
95
+ def __init__(self, ctx, max_tokens: int = 4096):
96
+ self._ctx = ctx
97
+ self._max_tokens = max_tokens
98
+
99
+ async def complete(self, prompt: str) -> str:
100
+ from mcp.types import SamplingMessage, TextContent
101
+
102
+ result = await self._ctx.session.create_message(
103
+ messages=[
104
+ SamplingMessage(role="user", content=TextContent(type="text", text=prompt))
105
+ ],
106
+ max_tokens=self._max_tokens,
107
+ )
108
+ content = result.content
109
+ return content.text if getattr(content, "type", None) == "text" else str(content)
110
+
111
+
112
+ def pick_backend(config: DocConfig, ctx=None) -> CompletionBackend:
113
+ """Choose a backend: provider key → retry-wrapped ProviderBackend;
114
+ MCP host ctx → SamplingBackend; otherwise raise BackendError.
115
+ """
116
+ from .retry import with_retry
117
+
118
+ if config.has_provider:
119
+ return with_retry(ProviderBackend(config))
120
+ if ctx is not None:
121
+ return SamplingBackend(ctx)
122
+ raise BackendError(
123
+ "No LLM available. Set a provider key (ANTHROPIC_API_KEY / OPENAI_API_KEY / "
124
+ "AZURE_OPENAI_API_KEY / AWS credentials), pass provider='ollama' for a local model, "
125
+ "or run inside an MCP host that supports sampling."
126
+ )
@@ -0,0 +1,71 @@
1
+ # core/cache.py
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+
8
+ MANIFEST_FILENAME = ".ai-docs-manifest.json"
9
+
10
+
11
+ def hash_file(repo_path: str, file_path: str) -> str:
12
+ """Return the SHA-256 hex digest of the content of *repo_path/file_path*."""
13
+ full = os.path.join(repo_path, file_path)
14
+ h = hashlib.sha256()
15
+ with open(full, "rb") as fh:
16
+ while chunk := fh.read(65536):
17
+ h.update(chunk)
18
+ return h.hexdigest()
19
+
20
+
21
+ def compute_hashes(files: list[str], repo_path: str) -> dict[str, str]:
22
+ """Return ``{relative_path: sha256}`` for every path in *files*."""
23
+ return {fp: hash_file(repo_path, fp) for fp in files}
24
+
25
+
26
+ def load_manifest(output_dir: str) -> dict[str, str] | None:
27
+ """Load the hash manifest from *output_dir*.
28
+
29
+ Returns ``None`` when no manifest exists or the file is corrupt.
30
+ """
31
+ path = os.path.join(output_dir, MANIFEST_FILENAME)
32
+ try:
33
+ with open(path, "r", encoding="utf-8") as fh:
34
+ return json.load(fh)
35
+ except (FileNotFoundError, json.JSONDecodeError):
36
+ return None
37
+
38
+
39
+ def save_manifest(file_hashes: dict[str, str], output_dir: str) -> None:
40
+ """Persist *file_hashes* as ``{output_dir}/.ai-docs-manifest.json``."""
41
+ os.makedirs(output_dir, exist_ok=True)
42
+ path = os.path.join(output_dir, MANIFEST_FILENAME)
43
+ with open(path, "w", encoding="utf-8") as fh:
44
+ json.dump(file_hashes, fh, indent=2, sort_keys=True)
45
+
46
+
47
+ def filter_changed(
48
+ files: list[str],
49
+ repo_path: str,
50
+ manifest: dict[str, str] | None,
51
+ ) -> tuple[list[str], list[str]]:
52
+ """Split *files* into ``(changed, unchanged)`` relative to *manifest*.
53
+
54
+ All files are returned as changed when *manifest* is ``None`` (first run).
55
+ Files that cannot be read are treated as changed.
56
+ """
57
+ if manifest is None:
58
+ return list(files), []
59
+ changed: list[str] = []
60
+ unchanged: list[str] = []
61
+ for fp in files:
62
+ try:
63
+ current_hash = hash_file(repo_path, fp)
64
+ except OSError:
65
+ changed.append(fp)
66
+ continue
67
+ if manifest.get(fp) == current_hash:
68
+ unchanged.append(fp)
69
+ else:
70
+ changed.append(fp)
71
+ return changed, unchanged
@@ -0,0 +1,92 @@
1
+ # core/config.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from dataclasses import dataclass, field, fields
6
+
7
+ DEFAULT_MODELS = {
8
+ "anthropic": "claude-sonnet-4-6",
9
+ "openai": "gpt-4o",
10
+ "azure": None,
11
+ "bedrock": "amazon.nova-pro-v1:0",
12
+ "ollama": "llama3.1",
13
+ }
14
+
15
+ LC_PROVIDER = {
16
+ "anthropic": "anthropic",
17
+ "openai": "openai",
18
+ "azure": "azure_openai",
19
+ "bedrock": "bedrock_converse",
20
+ "ollama": "ollama",
21
+ }
22
+
23
+ _PROVIDER_ENV = {
24
+ "anthropic": "ANTHROPIC_API_KEY",
25
+ "openai": "OPENAI_API_KEY",
26
+ "azure": "AZURE_OPENAI_API_KEY",
27
+ "bedrock": "AWS_ACCESS_KEY_ID",
28
+ }
29
+
30
+
31
+ @dataclass
32
+ class DocConfig:
33
+ provider: str | None = None
34
+ model: str | None = None
35
+ profile: str = "readme"
36
+ incremental: bool = True
37
+ diagrams: bool = True
38
+ max_file_size_kb: int = 100
39
+ max_concurrency: int = 8
40
+ pipeline_timeout_s: int = 300
41
+ # Per-request provider key (BYOK). repr=False so it can never leak via
42
+ # logging/str(config); it is handed to the provider client explicitly and
43
+ # never written to the environment.
44
+ api_key: str | None = field(default=None, repr=False)
45
+
46
+ @property
47
+ def has_provider(self) -> bool:
48
+ return self.provider is not None
49
+
50
+ @property
51
+ def model_id(self) -> str | None:
52
+ if not self.provider:
53
+ return None
54
+ model = self.model or DEFAULT_MODELS.get(self.provider)
55
+ return f"{self.provider}:{model}" if model else self.provider
56
+
57
+ @property
58
+ def lc_provider(self) -> str | None:
59
+ return LC_PROVIDER.get(self.provider, self.provider) if self.provider else None
60
+
61
+ @property
62
+ def resolved_model(self) -> str | None:
63
+ if not self.provider:
64
+ return None
65
+ return self.model or DEFAULT_MODELS.get(self.provider)
66
+
67
+
68
+ def detect_provider() -> str | None:
69
+ """Return the first provider whose credentials are present in the environment."""
70
+ for provider, env_var in _PROVIDER_ENV.items():
71
+ if os.getenv(env_var):
72
+ return provider
73
+ return None
74
+
75
+
76
+ def resolve_config(
77
+ provider: str | None = None,
78
+ model: str | None = None,
79
+ **overrides,
80
+ ) -> DocConfig:
81
+ """Build a DocConfig, auto-detecting the provider from env when not given."""
82
+ cfg = DocConfig(provider=provider or detect_provider(), model=model)
83
+ timeout_env = os.getenv("PIPELINE_TIMEOUT_S")
84
+ if timeout_env and timeout_env.isdigit():
85
+ cfg.pipeline_timeout_s = int(timeout_env)
86
+ valid_fields = {f.name for f in fields(DocConfig)}
87
+ for key, value in overrides.items():
88
+ if key not in valid_fields:
89
+ raise TypeError(f"resolve_config() got an unexpected keyword argument '{key}'")
90
+ if value is not None:
91
+ setattr(cfg, key, value)
92
+ return cfg
@@ -0,0 +1,212 @@
1
+ # ai_doc_creator/core/diagrams.py
2
+ """Deterministic Mermaid diagram generation + validation of LLM-drawn blocks.
3
+
4
+ The dependency and structure diagrams are produced by static analysis, never
5
+ by a model, so they are always syntactically valid. Model-drawn diagrams pass
6
+ through sanitize_mermaid_blocks(), which downgrades anything suspect to a
7
+ plain fenced block rather than letting it break the rendered page.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+
14
+ _PY_IMPORT = re.compile(
15
+ r"^\s*(?:from\s+([.\w]+)\s+import|import\s+([.\w]+))", re.MULTILINE
16
+ )
17
+ _JS_IMPORT = re.compile(
18
+ r"""(?:import\s+(?:[\w{}*,\s]+\s+from\s+)?|require\(\s*|import\(\s*)['"]([^'"]+)['"]"""
19
+ )
20
+ _JS_EXTENSIONS = (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs")
21
+ _JS_RESOLVE_SUFFIXES = ("", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
22
+ "/index.js", "/index.ts", "/index.jsx", "/index.tsx")
23
+
24
+ # First token of a fenced block must be one of these for the block to count
25
+ # as plausibly-valid Mermaid.
26
+ _MERMAID_TYPES = (
27
+ "graph", "flowchart", "sequencediagram", "classdiagram", "statediagram",
28
+ "statediagram-v2", "erdiagram", "journey", "pie", "gantt", "mindmap",
29
+ )
30
+ _MERMAID_FENCE = re.compile(r"```mermaid[ \t]*\n(.*?)```", re.DOTALL | re.IGNORECASE)
31
+
32
+ # Per-file read cap for import scanning: imports live at the top of files.
33
+ _SCAN_BYTES = 65536
34
+
35
+
36
+ def _max_nodes() -> int:
37
+ try:
38
+ return max(2, int(os.getenv("MAX_DIAGRAM_NODES", "40")))
39
+ except ValueError:
40
+ return 40
41
+
42
+
43
+ def _label(text: str) -> str:
44
+ """A node label safe inside Mermaid's ["..."] syntax."""
45
+ return text.replace('"', "'").replace("\n", " ")
46
+
47
+
48
+ def _read_head(path: str) -> str:
49
+ try:
50
+ with open(path, "r", encoding="utf-8", errors="ignore") as fh:
51
+ return fh.read(_SCAN_BYTES)
52
+ except OSError:
53
+ return ""
54
+
55
+
56
+ def _py_module(file_path: str) -> str:
57
+ mod = file_path[:-3].replace("\\", "/").replace("/", ".")
58
+ return mod[: -len(".__init__")] if mod.endswith(".__init__") else mod
59
+
60
+
61
+ def _resolve_py_import(name: str, importer: str, module_map: dict[str, str]) -> str | None:
62
+ """Map an import name (possibly relative) to an internal file, or None."""
63
+ if name.startswith("."):
64
+ level = len(name) - len(name.lstrip("."))
65
+ pkg_parts = os.path.dirname(importer).replace("\\", "/").split("/")
66
+ pkg_parts = [p for p in pkg_parts if p]
67
+ if level > 1:
68
+ pkg_parts = pkg_parts[: len(pkg_parts) - (level - 1)]
69
+ remainder = name.lstrip(".")
70
+ name = ".".join(pkg_parts + ([remainder] if remainder else []))
71
+ # Longest-prefix match: "a.b.c" may refer to module a/b.py's attribute c.
72
+ parts = name.split(".")
73
+ for end in range(len(parts), 0, -1):
74
+ candidate = ".".join(parts[:end])
75
+ if candidate in module_map:
76
+ return module_map[candidate]
77
+ return None
78
+
79
+
80
+ def _resolve_js_import(spec: str, importer: str, file_set: set[str]) -> str | None:
81
+ if not spec.startswith("."):
82
+ return None # external package
83
+ base = os.path.normpath(os.path.join(os.path.dirname(importer), spec))
84
+ base = base.replace("\\", "/")
85
+ for suffix in _JS_RESOLVE_SUFFIXES:
86
+ candidate = base + suffix
87
+ if candidate in file_set:
88
+ return candidate
89
+ return None
90
+
91
+
92
+ def _collect_edges(files: list[str], repo_path: str) -> set[tuple[str, str]]:
93
+ file_set = {f.replace("\\", "/") for f in files}
94
+ module_map = {_py_module(f): f for f in file_set if f.endswith(".py")}
95
+ edges: set[tuple[str, str]] = set()
96
+ for f in sorted(file_set):
97
+ content = _read_head(os.path.join(repo_path, f))
98
+ if not content:
99
+ continue
100
+ if f.endswith(".py"):
101
+ for match in _PY_IMPORT.finditer(content):
102
+ name = match.group(1) or match.group(2)
103
+ target = _resolve_py_import(name, f, module_map)
104
+ if target and target != f:
105
+ edges.add((f, target))
106
+ elif f.endswith(_JS_EXTENSIONS):
107
+ for match in _JS_IMPORT.finditer(content):
108
+ target = _resolve_js_import(match.group(1), f, file_set)
109
+ if target and target != f:
110
+ edges.add((f, target))
111
+ return edges
112
+
113
+
114
+ def build_dependency_mermaid(files: list[str], repo_path: str) -> str | None:
115
+ """Internal-module dependency graph as a fenced Mermaid block, or None
116
+ when there is nothing meaningful to draw."""
117
+ edges = _collect_edges(files, repo_path)
118
+ if len(edges) < 2:
119
+ return None
120
+
121
+ cap = _max_nodes()
122
+ # Keep the most-connected nodes so the capped diagram stays informative.
123
+ degree: dict[str, int] = {}
124
+ for a, b in edges:
125
+ degree[a] = degree.get(a, 0) + 1
126
+ degree[b] = degree.get(b, 0) + 1
127
+ kept = set(sorted(degree, key=lambda n: (-degree[n], n))[:cap])
128
+ kept_edges = sorted((a, b) for a, b in edges if a in kept and b in kept)
129
+ if len(kept_edges) < 2:
130
+ return None
131
+ dropped = len(degree) - len(kept)
132
+
133
+ ids = {n: f"n{i}" for i, n in enumerate(sorted(kept))}
134
+ lines = ["```mermaid", "graph LR"]
135
+ lines += [f' {ids[n]}["{_label(n)}"]' for n in sorted(kept)]
136
+ lines += [f" {ids[a]} --> {ids[b]}" for a, b in kept_edges]
137
+ lines.append("```")
138
+ if dropped > 0:
139
+ lines.append(f"\n*…plus {dropped} more module(s) not shown (MAX_DIAGRAM_NODES).*")
140
+ return "\n".join(lines)
141
+
142
+
143
+ def build_structure_mermaid(files: list[str]) -> str | None:
144
+ """Directory-tree chart as a fenced Mermaid block, or None for <2 files."""
145
+ normalized = sorted({f.replace("\\", "/") for f in files})
146
+ if len(normalized) < 2:
147
+ return None
148
+
149
+ cap = _max_nodes()
150
+ ids: dict[str, str] = {".": "n0"}
151
+ labels: dict[str, str] = {".": "(root)"}
152
+ edges: list[tuple[str, str]] = []
153
+ truncated = False
154
+
155
+ def _ensure(node: str, label: str) -> bool:
156
+ nonlocal truncated
157
+ if node in ids:
158
+ return True
159
+ if len(ids) >= cap:
160
+ truncated = True
161
+ return False
162
+ ids[node] = f"n{len(ids)}"
163
+ labels[node] = label
164
+ return True
165
+
166
+ for f in normalized:
167
+ parts = f.split("/")
168
+ parent = "."
169
+ for depth, part in enumerate(parts):
170
+ node = "/".join(parts[: depth + 1])
171
+ if not _ensure(node, part):
172
+ break
173
+ if (parent, node) not in edges:
174
+ edges.append((parent, node))
175
+ parent = node
176
+
177
+ lines = ["```mermaid", "graph TD"]
178
+ lines += [f' {ids[n]}["{_label(labels[n])}"]' for n in ids]
179
+ lines += [f" {ids[a]} --> {ids[b]}" for a, b in edges if a in ids and b in ids]
180
+ lines.append("```")
181
+ if truncated:
182
+ lines.append("\n*…tree truncated (MAX_DIAGRAM_NODES).*")
183
+ return "\n".join(lines)
184
+
185
+
186
+ def _is_plausible_mermaid(body: str) -> bool:
187
+ stripped = body.strip()
188
+ if not stripped:
189
+ return False
190
+ first_token = stripped.split()[0].lower()
191
+ if first_token not in _MERMAID_TYPES:
192
+ return False
193
+ for open_ch, close_ch in ("()", "[]", "{}"):
194
+ if stripped.count(open_ch) != stripped.count(close_ch):
195
+ return False
196
+ return True
197
+
198
+
199
+ def sanitize_mermaid_blocks(markdown: str) -> str:
200
+ """Downgrade implausible ```mermaid fences to plain ```text fences.
201
+
202
+ Model-drawn diagrams are useful but not trusted: an invalid block would
203
+ render as a raw error box on GitHub. Content is preserved either way.
204
+ """
205
+
206
+ def _check(match: re.Match) -> str:
207
+ body = match.group(1)
208
+ if _is_plausible_mermaid(body):
209
+ return match.group(0)
210
+ return f"```text\n{body}```"
211
+
212
+ return _MERMAID_FENCE.sub(_check, markdown)
@@ -0,0 +1,43 @@
1
+ import os
2
+ import logging
3
+ from typing import Dict
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class DocumentationWriter:
9
+ """Writes generated documentation to files."""
10
+
11
+ def __init__(self, output_dir: str):
12
+ self.output_dir = output_dir
13
+
14
+ def write_docs(self, documents: Dict[str, str], index_content: str):
15
+ """Writes the documentation files and the index file."""
16
+ try:
17
+ # Create output directory if it doesn't exist
18
+ os.makedirs(self.output_dir, exist_ok=True)
19
+
20
+ # Write individual file docs
21
+ for file_path, content in documents.items():
22
+ # We want to mirror the structure inside the output dir
23
+ # e.g., src/main.py -> docs/src/main.py.md
24
+
25
+ relative_path = file_path + ".md"
26
+ full_path = os.path.join(self.output_dir, relative_path)
27
+
28
+ # Ensure the directory for this file exists
29
+ os.makedirs(os.path.dirname(full_path), exist_ok=True)
30
+
31
+ with open(full_path, "w", encoding="utf-8") as f:
32
+ f.write(content)
33
+ logger.info(f"Written documentation for {file_path} to {full_path}")
34
+
35
+ # Write index file
36
+ index_path = os.path.join(self.output_dir, "README.md")
37
+ with open(index_path, "w", encoding="utf-8") as f:
38
+ f.write(index_content)
39
+ logger.info(f"Written index to {index_path}")
40
+
41
+ except Exception as e:
42
+ logger.error(f"Failed to write documentation: {e}")
43
+ raise