docwright 0.1.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.
Files changed (41) hide show
  1. ai_docgen/__init__.py +0 -0
  2. ai_docgen/analyzer.py +59 -0
  3. ai_docgen/built_in_templates/__init__.py +0 -0
  4. ai_docgen/built_in_templates/readme/__init__.py +0 -0
  5. ai_docgen/built_in_templates/readme/default.md.j2 +30 -0
  6. ai_docgen/built_in_templates/wiki/__init__.py +0 -0
  7. ai_docgen/built_in_templates/wiki/adr.md.j2 +27 -0
  8. ai_docgen/built_in_templates/wiki/api-contracts.md.j2 +20 -0
  9. ai_docgen/built_in_templates/wiki/architecture.md.j2 +25 -0
  10. ai_docgen/built_in_templates/wiki/data-model.md.j2 +22 -0
  11. ai_docgen/built_in_templates/wiki/db-schema.md.j2 +22 -0
  12. ai_docgen/built_in_templates/wiki/development-guide.md.j2 +22 -0
  13. ai_docgen/built_in_templates/wiki/integrations.md.j2 +22 -0
  14. ai_docgen/built_in_templates/wiki/operations.md.j2 +27 -0
  15. ai_docgen/built_in_templates/wiki/security.md.j2 +22 -0
  16. ai_docgen/built_in_templates/wiki/troubleshooting.md.j2 +22 -0
  17. ai_docgen/cli.py +136 -0
  18. ai_docgen/config.py +71 -0
  19. ai_docgen/engine.py +165 -0
  20. ai_docgen/outputs/__init__.py +0 -0
  21. ai_docgen/outputs/base.py +7 -0
  22. ai_docgen/outputs/direct.py +16 -0
  23. ai_docgen/outputs/factory.py +12 -0
  24. ai_docgen/outputs/pull_request.py +69 -0
  25. ai_docgen/providers/__init__.py +0 -0
  26. ai_docgen/providers/base.py +6 -0
  27. ai_docgen/providers/claude.py +22 -0
  28. ai_docgen/providers/factory.py +26 -0
  29. ai_docgen/providers/ollama.py +25 -0
  30. ai_docgen/providers/openai.py +20 -0
  31. ai_docgen/registry.py +125 -0
  32. ai_docgen/renderer.py +104 -0
  33. ai_docgen/reporters/__init__.py +0 -0
  34. ai_docgen/reporters/html.py +54 -0
  35. ai_docgen/reporters/terminal.py +31 -0
  36. ai_docgen/scaffolder.py +163 -0
  37. docwright-0.1.0.dist-info/METADATA +188 -0
  38. docwright-0.1.0.dist-info/RECORD +41 -0
  39. docwright-0.1.0.dist-info/WHEEL +4 -0
  40. docwright-0.1.0.dist-info/entry_points.txt +3 -0
  41. docwright-0.1.0.dist-info/licenses/LICENSE +21 -0
ai_docgen/engine.py ADDED
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from ai_docgen.analyzer import DiffAnalyzer
7
+ from ai_docgen.config import Config
8
+ from ai_docgen.outputs.base import Output
9
+ from ai_docgen.providers.base import LLMProvider
10
+ from ai_docgen.registry import DocumentEntry, ProjectEntry, Registry
11
+ from ai_docgen.renderer import DocumentRenderer, TemplateLoader
12
+
13
+ SYSTEM_PROMPT = (
14
+ "You are a technical documentation writer. You update specific sections of documentation "
15
+ "based on code changes. Return ONLY the updated section content — no markdown fences, "
16
+ "no explanations, no surrounding text. Write in clear, concise English. "
17
+ "Be accurate and specific to the actual code."
18
+ )
19
+
20
+
21
+ class DocsEngine:
22
+ def __init__(self, repo_root: Path, provider: LLMProvider, output: Output) -> None:
23
+ self.repo_root = repo_root
24
+ self.provider = provider
25
+ self.output = output
26
+ self.config = Config.load(repo_root)
27
+ self.renderer = DocumentRenderer()
28
+ self.loader = TemplateLoader(
29
+ source=self.config.templates.source,
30
+ local_path=(
31
+ repo_root / self.config.templates.local_path
32
+ if self.config.templates.source == "local"
33
+ else None
34
+ ),
35
+ )
36
+
37
+ async def init(self) -> None:
38
+ changed_files: list[Path] = []
39
+ for doc_config in self.config.documents:
40
+ target = self.repo_root / doc_config.target
41
+ template_text = self.loader.load(doc_config.template)
42
+ document = target.read_text() if target.exists() else template_text
43
+ section_names = self.renderer.auto_section_names(template_text)
44
+ repo_context = self.gather_repo_context()
45
+ for section_name in section_names:
46
+ user_prompt = (
47
+ f"Repository context:\n{repo_context}\n\n"
48
+ f"Document type: {doc_config.type}\n"
49
+ f"Update the '{section_name}' section with accurate, detailed information."
50
+ )
51
+ updated_content = await self.provider.complete(
52
+ system=SYSTEM_PROMPT, user=user_prompt
53
+ )
54
+ document = self.renderer.patch_section(
55
+ document, section_name, updated_content + "\n"
56
+ )
57
+ target.parent.mkdir(parents=True, exist_ok=True)
58
+ target.write_text(document)
59
+ changed_files.append(target)
60
+ if changed_files:
61
+ self.output.apply(changed_files, "docs: generate initial documentation")
62
+ Config.mark_initialized(self.repo_root)
63
+ self.register_in_registry()
64
+
65
+ async def run(self, diff_text: str) -> bool:
66
+ if not Config.is_initialized(self.repo_root):
67
+ await self.init()
68
+ return False
69
+ triggers = self.config.triggers
70
+ analyzer = DiffAnalyzer(
71
+ diff_text=diff_text,
72
+ trigger_paths=triggers.paths if triggers else [],
73
+ ignore_paths=triggers.ignore if triggers else [],
74
+ )
75
+ if not analyzer.has_relevant_changes():
76
+ return True
77
+ changed_files: list[Path] = []
78
+ diff_summary = analyzer.diff_summary()
79
+ for doc_config in self.config.documents:
80
+ target = self.repo_root / doc_config.target
81
+ if not target.exists():
82
+ continue
83
+ document = target.read_text()
84
+ section_names = self.renderer.auto_section_names(document)
85
+ updated = False
86
+ for section_name in section_names:
87
+ user_prompt = (
88
+ f"Diff summary:\n{diff_summary}\n\n"
89
+ f"Current document:\n{document}\n\n"
90
+ f"Update the '{section_name}' section if the diff affects it. "
91
+ f"If no update is needed, return the current section content unchanged."
92
+ )
93
+ updated_content = await self.provider.complete(
94
+ system=SYSTEM_PROMPT, user=user_prompt
95
+ )
96
+ new_document = self.renderer.patch_section(
97
+ document, section_name, updated_content + "\n"
98
+ )
99
+ if new_document != document:
100
+ document = new_document
101
+ updated = True
102
+ if updated:
103
+ target.write_text(document)
104
+ changed_files.append(target)
105
+ if changed_files:
106
+ self.output.apply(changed_files, "docs: update documentation")
107
+ return False
108
+
109
+ async def sync(self) -> None:
110
+ for doc_config in self.config.documents:
111
+ target = self.repo_root / doc_config.target
112
+ template_text = self.loader.load(doc_config.template)
113
+ document = target.read_text() if target.exists() else template_text
114
+ section_names = self.renderer.auto_section_names(template_text)
115
+ repo_context = self.gather_repo_context()
116
+ for section_name in section_names:
117
+ user_prompt = (
118
+ f"Repository context:\n{repo_context}\n\n"
119
+ f"Current document:\n{document}\n\n"
120
+ f"Re-sync the '{section_name}' section to be accurate and up to date."
121
+ )
122
+ updated_content = await self.provider.complete(
123
+ system=SYSTEM_PROMPT, user=user_prompt
124
+ )
125
+ document = self.renderer.patch_section(
126
+ document, section_name, updated_content + "\n"
127
+ )
128
+ target.parent.mkdir(parents=True, exist_ok=True)
129
+ target.write_text(document)
130
+
131
+ def gather_repo_context(self) -> str:
132
+ lines: list[str] = [f"Repo root: {self.repo_root.name}"]
133
+ for candidate in ["pyproject.toml", "package.json", "composer.json", "go.mod"]:
134
+ path = self.repo_root / candidate
135
+ if path.exists():
136
+ lines.append(f"\n{candidate}:\n{path.read_text()[:2000]}")
137
+ break
138
+ src_dirs = ["app", "src", "lib"]
139
+ for src_dir in src_dirs:
140
+ full = self.repo_root / src_dir
141
+ if full.exists():
142
+ files = [str(p.relative_to(self.repo_root)) for p in full.rglob("*.py")][:20]
143
+ lines.append(f"\nSource files in {src_dir}/: {', '.join(files)}")
144
+ break
145
+ return "\n".join(lines)
146
+
147
+ def register_in_registry(self) -> None:
148
+ registry_path = self.repo_root / self.config.registry.path
149
+ registry = Registry(registry_path)
150
+ try:
151
+ remote = (
152
+ subprocess.check_output(["git", "remote", "get-url", "origin"], cwd=self.repo_root)
153
+ .decode()
154
+ .strip()
155
+ )
156
+ except subprocess.CalledProcessError:
157
+ remote = ""
158
+ registry.register(
159
+ ProjectEntry(
160
+ name=self.repo_root.name,
161
+ path=str(self.repo_root),
162
+ remote=remote,
163
+ documents=[DocumentEntry(target=d.target) for d in self.config.documents],
164
+ )
165
+ )
File without changes
@@ -0,0 +1,7 @@
1
+ from abc import ABC, abstractmethod
2
+ from pathlib import Path
3
+
4
+
5
+ class Output(ABC):
6
+ @abstractmethod
7
+ def apply(self, changed_files: list[Path], message: str) -> None: ...
@@ -0,0 +1,16 @@
1
+ from pathlib import Path
2
+
3
+ from git import Repo
4
+
5
+ from ai_docgen.outputs.base import Output
6
+
7
+
8
+ class DirectOutput(Output):
9
+ def __init__(self, repo_root: Path) -> None:
10
+ self.repo_root = repo_root
11
+
12
+ def apply(self, changed_files: list[Path], message: str) -> None:
13
+ repo = Repo(self.repo_root)
14
+ str_paths = [str(f.relative_to(self.repo_root)) for f in changed_files]
15
+ repo.index.add(str_paths)
16
+ repo.index.commit(message)
@@ -0,0 +1,12 @@
1
+ from pathlib import Path
2
+
3
+ from ai_docgen.config import OutputConfig
4
+ from ai_docgen.outputs.base import Output
5
+ from ai_docgen.outputs.direct import DirectOutput
6
+ from ai_docgen.outputs.pull_request import PullRequestOutput
7
+
8
+
9
+ def build_output(config: OutputConfig, repo_root: Path) -> Output:
10
+ if config.mode == "direct":
11
+ return DirectOutput(repo_root=repo_root)
12
+ return PullRequestOutput(repo_root=repo_root, config=config)
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import re
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+
11
+ from ai_docgen.config import OutputConfig
12
+ from ai_docgen.outputs.base import Output
13
+
14
+
15
+ class PullRequestOutput(Output):
16
+ def __init__(self, repo_root: Path, config: OutputConfig) -> None:
17
+ self.repo_root = repo_root
18
+ self.config = config
19
+
20
+ def apply(self, changed_files: list[Path], message: str) -> None:
21
+ short_hash = hashlib.sha1(message.encode()).hexdigest()[:8]
22
+ branch = f"{self.config.branch_prefix}{short_hash}"
23
+ self.run(["git", "checkout", "-b", branch])
24
+ rel_paths = [str(f.relative_to(self.repo_root)) for f in changed_files]
25
+ self.run(["git", "add"] + rel_paths)
26
+ self.run(["git", "commit", "-m", message])
27
+ self.run(["git", "push", "origin", branch])
28
+ token = os.environ.get("GITHUB_TOKEN", "")
29
+ if token:
30
+ self.create_github_pr(branch, message, token)
31
+
32
+ def run(self, cmd: list[str]) -> None:
33
+ subprocess.run(cmd, cwd=self.repo_root, check=True)
34
+
35
+ def create_github_pr(self, branch: str, title: str, token: str) -> None:
36
+ remote_url = (
37
+ subprocess.check_output(["git", "remote", "get-url", "origin"], cwd=self.repo_root)
38
+ .decode()
39
+ .strip()
40
+ )
41
+ owner_repo = self.extract_owner_repo(remote_url)
42
+ if not owner_repo:
43
+ return
44
+ default_branch = (
45
+ subprocess.check_output(
46
+ ["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
47
+ cwd=self.repo_root,
48
+ )
49
+ .decode()
50
+ .strip()
51
+ .replace("refs/remotes/origin/", "")
52
+ )
53
+ httpx.post(
54
+ f"https://api.github.com/repos/{owner_repo}/pulls",
55
+ headers={
56
+ "Authorization": f"Bearer {token}",
57
+ "Accept": "application/vnd.github+json",
58
+ },
59
+ json={
60
+ "title": self.config.pr_title,
61
+ "head": branch,
62
+ "base": default_branch,
63
+ "body": title,
64
+ },
65
+ )
66
+
67
+ def extract_owner_repo(self, remote_url: str) -> str | None:
68
+ match = re.search(r"[:/]([^/]+/[^/]+?)(?:\.git)?$", remote_url)
69
+ return match.group(1) if match else None
File without changes
@@ -0,0 +1,6 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class LLMProvider(ABC):
5
+ @abstractmethod
6
+ async def complete(self, system: str, user: str) -> str: ...
@@ -0,0 +1,22 @@
1
+ from anthropic import AsyncAnthropic
2
+ from anthropic.types import TextBlock
3
+
4
+ from ai_docgen.providers.base import LLMProvider
5
+
6
+
7
+ class ClaudeProvider(LLMProvider):
8
+ def __init__(self, model: str, api_key: str) -> None:
9
+ self.model = model
10
+ self.client = AsyncAnthropic(api_key=api_key)
11
+
12
+ async def complete(self, system: str, user: str) -> str:
13
+ message = await self.client.messages.create(
14
+ model=self.model,
15
+ max_tokens=4096,
16
+ system=system,
17
+ messages=[{"role": "user", "content": user}],
18
+ )
19
+ block = message.content[0]
20
+ if not isinstance(block, TextBlock):
21
+ raise ValueError(f"Unexpected content block type: {type(block)}")
22
+ return block.text
@@ -0,0 +1,26 @@
1
+ import os
2
+
3
+ from ai_docgen.config import ProviderConfig
4
+ from ai_docgen.providers.base import LLMProvider
5
+ from ai_docgen.providers.claude import ClaudeProvider
6
+ from ai_docgen.providers.ollama import OllamaProvider
7
+ from ai_docgen.providers.openai import OpenAIProvider
8
+
9
+
10
+ def build_provider(config: ProviderConfig) -> LLMProvider:
11
+ if config.type == "ollama":
12
+ return OllamaProvider(
13
+ model=config.model,
14
+ base_url=config.base_url or "http://localhost:11434",
15
+ )
16
+ api_key = os.environ.get(config.api_key_env, "")
17
+ if not api_key:
18
+ raise OSError(
19
+ f"Environment variable '{config.api_key_env}' is not set. "
20
+ f"Required for provider '{config.type}'."
21
+ )
22
+ if config.type == "claude":
23
+ return ClaudeProvider(model=config.model, api_key=api_key)
24
+ if config.type == "openai":
25
+ return OpenAIProvider(model=config.model, api_key=api_key)
26
+ raise ValueError(f"Unknown provider type: {config.type}")
@@ -0,0 +1,25 @@
1
+ import httpx
2
+
3
+ from ai_docgen.providers.base import LLMProvider
4
+
5
+
6
+ class OllamaProvider(LLMProvider):
7
+ def __init__(self, model: str, base_url: str = "http://localhost:11434") -> None:
8
+ self.model = model
9
+ self.base_url = base_url.rstrip("/")
10
+
11
+ async def complete(self, system: str, user: str) -> str:
12
+ async with httpx.AsyncClient(timeout=120) as client:
13
+ response = await client.post(
14
+ f"{self.base_url}/api/chat",
15
+ json={
16
+ "model": self.model,
17
+ "stream": False,
18
+ "messages": [
19
+ {"role": "system", "content": system},
20
+ {"role": "user", "content": user},
21
+ ],
22
+ },
23
+ )
24
+ response.raise_for_status()
25
+ return str(response.json()["message"]["content"])
@@ -0,0 +1,20 @@
1
+ from openai import AsyncOpenAI
2
+
3
+ from ai_docgen.providers.base import LLMProvider
4
+
5
+
6
+ class OpenAIProvider(LLMProvider):
7
+ def __init__(self, model: str, api_key: str) -> None:
8
+ self.model = model
9
+ self.client = AsyncOpenAI(api_key=api_key)
10
+
11
+ async def complete(self, system: str, user: str) -> str:
12
+ response = await self.client.chat.completions.create(
13
+ model=self.model,
14
+ messages=[
15
+ {"role": "system", "content": system},
16
+ {"role": "user", "content": user},
17
+ ],
18
+ )
19
+ content = response.choices[0].message.content
20
+ return content or ""
ai_docgen/registry.py ADDED
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import date, timedelta
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+ STALE_DAYS = 30
10
+
11
+
12
+ def _as_date(value: date | str | None, fallback: date) -> date:
13
+ if value is None:
14
+ return fallback
15
+ if isinstance(value, date):
16
+ return value
17
+ return date.fromisoformat(value)
18
+
19
+
20
+ def _as_optional_date(value: date | str | None) -> date | None:
21
+ if value is None:
22
+ return None
23
+ if isinstance(value, date):
24
+ return value
25
+ return date.fromisoformat(value)
26
+
27
+
28
+ @dataclass
29
+ class DocumentEntry:
30
+ target: str
31
+ last_updated: date | None = None
32
+
33
+
34
+ @dataclass
35
+ class ProjectEntry:
36
+ name: str
37
+ path: str
38
+ remote: str
39
+ documents: list[DocumentEntry] = field(default_factory=list)
40
+ registered_at: date = field(default_factory=date.today)
41
+ last_updated: date = field(default_factory=date.today)
42
+ status: str = "synced"
43
+
44
+ def compute_status(self) -> str:
45
+ if (date.today() - self.last_updated) > timedelta(days=STALE_DAYS):
46
+ return "stale"
47
+ return "synced"
48
+
49
+
50
+ class Registry:
51
+ def __init__(self, path: Path) -> None:
52
+ self.path = path
53
+
54
+ def all_projects(self) -> list[ProjectEntry]:
55
+ if not self.path.exists():
56
+ return []
57
+ data = yaml.safe_load(self.path.read_text()) or {}
58
+ projects = []
59
+ for p in data.get("projects", []):
60
+ docs = [
61
+ DocumentEntry(
62
+ target=d["target"],
63
+ last_updated=_as_optional_date(d.get("last_updated")),
64
+ )
65
+ for d in p.get("documents", [])
66
+ ]
67
+ projects.append(
68
+ ProjectEntry(
69
+ name=p["name"],
70
+ path=p["path"],
71
+ remote=p.get("remote", ""),
72
+ documents=docs,
73
+ registered_at=_as_date(p.get("registered_at"), date.today()),
74
+ last_updated=_as_date(p.get("last_updated"), date.today()),
75
+ status=p.get("status", "synced"),
76
+ )
77
+ )
78
+ return projects
79
+
80
+ def register(self, entry: ProjectEntry) -> None:
81
+ projects = self.all_projects()
82
+ existing = next((p for p in projects if p.name == entry.name), None)
83
+ if existing:
84
+ projects.remove(existing)
85
+ entry.last_updated = date.today()
86
+ entry.status = entry.compute_status()
87
+ projects.append(entry)
88
+ self.write(projects)
89
+
90
+ def update_document_timestamp(self, project_name: str, target: str, updated: date) -> None:
91
+ projects = self.all_projects()
92
+ for project in projects:
93
+ if project.name == project_name:
94
+ for doc in project.documents:
95
+ if doc.target == target:
96
+ doc.last_updated = updated
97
+ project.last_updated = updated
98
+ project.status = project.compute_status()
99
+ self.write(projects)
100
+
101
+ def write(self, projects: list[ProjectEntry]) -> None:
102
+ self.path.parent.mkdir(parents=True, exist_ok=True)
103
+ data = {
104
+ "projects": [
105
+ {
106
+ "name": p.name,
107
+ "path": p.path,
108
+ "remote": p.remote,
109
+ "registered_at": p.registered_at.isoformat(),
110
+ "last_updated": p.last_updated.isoformat(),
111
+ "status": p.status,
112
+ "documents": [
113
+ {
114
+ "target": d.target,
115
+ "last_updated": (
116
+ d.last_updated.isoformat() if d.last_updated else None
117
+ ),
118
+ }
119
+ for d in p.documents
120
+ ],
121
+ }
122
+ for p in projects
123
+ ]
124
+ }
125
+ self.path.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True))
ai_docgen/renderer.py ADDED
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from jinja2 import Environment, select_autoescape
8
+
9
+
10
+ @dataclass
11
+ class Section:
12
+ name: str
13
+ is_auto: bool
14
+ content: str
15
+ start: int
16
+ end: int
17
+
18
+
19
+ AUTO_OPEN = re.compile(r"<!-- AUTO:(\w+) -->")
20
+ AUTO_CLOSE = re.compile(r"<!-- /AUTO:(\w+) -->")
21
+ MANUAL_OPEN = re.compile(r"<!-- MANUAL -->")
22
+ MANUAL_CLOSE = re.compile(r"<!-- /MANUAL -->")
23
+
24
+ BUILTIN_TEMPLATES_DIR = Path(__file__).parent / "built_in_templates"
25
+
26
+
27
+ class DocumentRenderer:
28
+ def parse_sections(self, text: str) -> list[Section]:
29
+ sections: list[Section] = []
30
+ lines = text.splitlines(keepends=True)
31
+ i = 0
32
+ while i < len(lines):
33
+ auto_match = AUTO_OPEN.match(lines[i].strip())
34
+ manual_match = MANUAL_OPEN.match(lines[i].strip())
35
+ if auto_match:
36
+ name = auto_match.group(1)
37
+ start = i
38
+ content_lines: list[str] = []
39
+ i += 1
40
+ while i < len(lines) and not AUTO_CLOSE.match(lines[i].strip()):
41
+ content_lines.append(lines[i])
42
+ i += 1
43
+ sections.append(
44
+ Section(
45
+ name=name,
46
+ is_auto=True,
47
+ content="".join(content_lines),
48
+ start=start,
49
+ end=i,
50
+ )
51
+ )
52
+ elif manual_match:
53
+ start = i
54
+ content_lines = []
55
+ i += 1
56
+ while i < len(lines) and not MANUAL_CLOSE.match(lines[i].strip()):
57
+ content_lines.append(lines[i])
58
+ i += 1
59
+ sections.append(
60
+ Section(
61
+ name="MANUAL",
62
+ is_auto=False,
63
+ content="".join(content_lines),
64
+ start=start,
65
+ end=i,
66
+ )
67
+ )
68
+ i += 1
69
+ return sections
70
+
71
+ def auto_section_names(self, text: str) -> list[str]:
72
+ return [s.name for s in self.parse_sections(text) if s.is_auto]
73
+
74
+ def patch_section(self, text: str, section_name: str, new_content: str) -> str:
75
+ open_marker = f"<!-- AUTO:{section_name} -->"
76
+ close_marker = f"<!-- /AUTO:{section_name} -->"
77
+ if open_marker not in text:
78
+ return text
79
+ before, rest = text.split(open_marker, 1)
80
+ _, after = rest.split(close_marker, 1)
81
+ return f"{before}{open_marker}\n{new_content}{close_marker}{after}"
82
+
83
+
84
+ class TemplateLoader:
85
+ def __init__(self, source: str = "builtin", local_path: Path | None = None) -> None:
86
+ self.source = source
87
+ self.local_path = local_path
88
+
89
+ def load(self, template_name: str) -> str:
90
+ template_path = f"{template_name}.md.j2"
91
+ if self.source == "local" and self.local_path:
92
+ full_path = self.local_path / template_path
93
+ if full_path.exists():
94
+ return full_path.read_text()
95
+ builtin_path = BUILTIN_TEMPLATES_DIR / template_path
96
+ if not builtin_path.exists():
97
+ raise FileNotFoundError(f"Template not found: {template_name}")
98
+ return builtin_path.read_text()
99
+
100
+ def render(self, template_name: str, context: dict[str, str]) -> str:
101
+ template_content = self.load(template_name)
102
+ env = Environment(autoescape=select_autoescape([]))
103
+ tmpl = env.from_string(template_content)
104
+ return tmpl.render(**context)
File without changes
@@ -0,0 +1,54 @@
1
+ from pathlib import Path
2
+
3
+ from ai_docgen.registry import Registry
4
+
5
+ HTML_TEMPLATE = """\
6
+ <!DOCTYPE html>
7
+ <html lang="en">
8
+ <head>
9
+ <meta charset="UTF-8">
10
+ <title>ai-docgen — Project Status</title>
11
+ <style>
12
+ body {{
13
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
14
+ max-width: 900px; margin: 40px auto; padding: 0 20px; color: #333;
15
+ }}
16
+ h1 {{ color: #1a1a2e; }}
17
+ table {{ width: 100%; border-collapse: collapse; margin-top: 20px; }}
18
+ th {{ background: #1a1a2e; color: white; padding: 10px 14px; text-align: left; }}
19
+ td {{ padding: 10px 14px; border-bottom: 1px solid #eee; }}
20
+ tr:hover td {{ background: #f9f9f9; }}
21
+ .synced {{ color: #22863a; font-weight: 600; }}
22
+ .stale {{ color: #cb2431; font-weight: 600; }}
23
+ footer {{ margin-top: 40px; color: #999; font-size: 0.85em; }}
24
+ </style>
25
+ </head>
26
+ <body>
27
+ <h1>ai-docgen — Project Status</h1>
28
+ <table>
29
+ <thead><tr><th>Project</th><th>Status</th><th>Last Updated</th><th>Documents</th></tr></thead>
30
+ <tbody>
31
+ {rows}
32
+ </tbody>
33
+ </table>
34
+ <footer>Generated by ai-docgen</footer>
35
+ </body>
36
+ </html>
37
+ """
38
+
39
+
40
+ def render_html_report(registry_path: Path, output_path: Path) -> None:
41
+ registry = Registry(registry_path)
42
+ projects = registry.all_projects()
43
+ rows: list[str] = []
44
+ for project in projects:
45
+ doc_links = ", ".join(f'<a href="{d.target}">{d.target}</a>' for d in project.documents)
46
+ rows.append(
47
+ f" <tr>"
48
+ f"<td>{project.name}</td>"
49
+ f'<td class="{project.status}">{project.status}</td>'
50
+ f"<td>{project.last_updated}</td>"
51
+ f"<td>{doc_links}</td>"
52
+ f"</tr>"
53
+ )
54
+ output_path.write_text(HTML_TEMPLATE.format(rows="\n".join(rows)))