1"""
2Quarto renderer implementation.
3
4Implements :any:`/specs/spec_renderer_quarto` §1-8, covering:
5
6* Directory and naming conventions (§1)
7* index.qmd structure (§2)
8* Summary and detailed sections (§2-3)
9* Error handling (§6)
10* Implementation interface (§8)
11"""
12
13import yaml
14from ..rendering.table import render_table
15from ..models.overview_data import OverviewData
16from typing import List
17from pathlib import Path
18from .renderer_base import Renderer
19from ..services.sort_utils import sort_overview
20from ..services.readme_extraction import parse_readme
21
22
23class SafeDumperWithQuotedStrings(yaml.SafeDumper):
24 """Custom YAML dumper that quotes all strings."""
25
26 def represent_str(self, data):
27 return self.represent_scalar("tag:yaml.org,2002:str", data, style='"')
28
29
30SafeDumperWithQuotedStrings.add_representer(
31 str, SafeDumperWithQuotedStrings.represent_str
32)
33
34
35def dump_yaml(data):
36 """Helper function to dump YAML with quoted strings."""
37 return yaml.dump(
38 data, Dumper=SafeDumperWithQuotedStrings, sort_keys=False, allow_unicode=True
39 )
40
41
42# --- Custom YAML Dumper to quote only string values, not keys ---
43class QuotedStr(str):
44 pass
45
46
47def quoted_str_representer(dumper, data):
48 """Custom representer for strings that need to be quoted in YAML."""
49 return dumper.represent_scalar("tag:yaml.org,2002:str", str(data), style='"')
50
51
52yaml.add_representer(QuotedStr, quoted_str_representer)
53yaml.add_representer(str, quoted_str_representer) # Force quote all strings
54
55
56def force_quote_strs(obj):
57 """Recursively convert all strings in an object to QuotedStr."""
58 if isinstance(obj, str):
59 return QuotedStr(obj)
60 elif isinstance(obj, list):
61 return [force_quote_strs(i) for i in obj]
62 elif isinstance(obj, dict):
63 return {k: force_quote_strs(v) for k, v in obj.items()}
64 else:
65 return obj
66
67
[docs]
68class QuartoRenderer(Renderer):
[docs]
69 def __init__(self, table_config: dict):
70 self.table_config = table_config
71
[docs]
72 def render(
73 self, overview_data: List[OverviewData], base_dir: str = "quarto"
74 ) -> str:
75 """
76 Render the full index.qmd as a string.
77 Implements Renderer.render().
78 See /specs/spec_renderer_quarto.md §6 for required output.
79 """
80 return self.render_index(overview_data, base_dir)
81
[docs]
82 def render_index(
83 self, overview_data: List[OverviewData], base_dir: str = "quarto"
84 ) -> str:
85 """
86 Render the full index.qmd with all tables and detailed sections.
87 """
88 # Sort the overview data according to table_config
89 overview_data = sort_overview(overview_data, self.table_config)
90
91 # YAML frontmatter
92 frontmatter = {
93 "title": "Gitlab-Übersicht",
94 "format": "confluence-html",
95 "toc": True,
96 }
97 out = f"---\n{yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True)}---\n\n"
98
99 # Group data by group name
100 group_map = {}
101 for od in overview_data:
102 group_map.setdefault(od.group.name, []).append(od)
103
104 # Sort groups by number of projects
105 sorted_groups = sorted(
106 group_map.items(), key=lambda item: (-len(item[1]), item[0].lower())
107 )
108
109 # --- 1. Summary Section ---
110 out += "## Summary\n\n"
111 for group_name, ods in sorted_groups:
112 out += f"### {group_name}\n\n"
113
114 # Prepare data for table rendering
115 table_data = []
116 for od in ods:
117 p = od.project
118 row = od.model_dump()
119
120 group_dir_name = od.group.path or od.group.name.replace(
121 " ", "-"
122 ).replace("/", "-")
123 pname = p.path or p.name.replace(" ", "-").replace("/", "-")
124 link = f"./{group_dir_name}/{pname}.qmd"
125
126 row["repo"] = f"[{p.name}]({link})"
127 if p.avatar_url:
128 row["repo"] = (
129 f"{{width=16px}} {row['repo']}"
130 )
131 table_data.append((p.name, row))
132
133 # Render table for the group
134 out += render_table(table_data, self.table_config)
135 out += "\n"
136 out += "Note: If no supervisors were found, authors of the README are named as supervisors.\n\n"
137
138 # --- 2. Detailed Section ---
139 for group_name, ods in sorted_groups:
140 out += f"## Group {group_name}\n\n"
141
142 # Data in Detailed Section is to be sorted alphabetically according to :any: is already sorted, so we just need to maintain the order
143 # within each group
144 for od in sorted(ods, key=lambda p: p.project.name.lower()):
145 out += self.render_project_section(od)
146 return out
147
[docs]
148 def render_project_section(self, od: OverviewData) -> str:
149 """Render a detailed section for a project, as in legacy index.qmd."""
150 p = od.project
151 g = od.group
152 section = f"### {p.name}\n\n"
153
154 details = {}
155 if (
156 "date" in od.extra.raw_frontmatter
157 and od.extra.raw_frontmatter["date"] != "-"
158 ):
159 details["Date"] = od.extra.raw_frontmatter["date"]
160
161 status = (
162 od.extra.raw_frontmatter.get("status")
163 if "status" in od.extra.raw_frontmatter
164 else getattr(p, "status", None)
165 )
166 if status and status != "-":
167 details["Status"] = status
168
169 supervisors = (
170 "; ".join(od.extra.supervisors)
171 if od.extra.supervisors
172 else (
173 "Authors: " + "; ".join(od.extra.authors) if od.extra.authors else None
174 )
175 )
176 if supervisors and supervisors != "-":
177 details["Supervisors"] = supervisors
178
179 if details:
180 for key, value in details.items():
181 if isinstance(value, list):
182 section += f"{key}\n"
183 for item in value:
184 section += f": {item}\n"
185 section += "\n"
186 else:
187 section += f"{key}\n: {value}\n\n"
188
189 if p.web_url:
190 section += f"[]({p.web_url}/-/releases)\n\n"
191
192 # Add callouts (tip, note, etc.)
193 if (
194 od.readme
195 and od.readme.extra.raw_frontmatter
196 and "description" in od.readme.extra.raw_frontmatter
197 ):
198 section += "> [!tip] Description\n>\n"
199 section += f"> {od.readme.extra.raw_frontmatter['description']}\n\n"
200 elif od.readme and od.readme.content and od.readme.content.strip():
201 # Use parse_readme to strip YAML front-matter
202 parsed = parse_readme(od.readme.content)
203 cleaned_content = parsed["content"]
204 # Truncate to first 10 non-empty lines
205 lines = [
206 "> " + line for line in cleaned_content.split("\n") if line.strip()
207 ]
208 truncated = lines[:10]
209 truncated_content = "\n".join(truncated)
210 if len(lines) > 10:
211 truncated_content += "\n> …continues…"
212 section += f"> [!warning] Description\n>\n> {truncated_content}\n\n"
213 elif not od.readme or not od.readme.content:
214 section += "> [!danger] Description\n>\n> No Readme found\n\n"
215
216 # Issues summary
217 if od.issues:
218 section += f"> [!note] Open Issues \n> \n"
219 for issue in od.issues:
220 section += f"> - [{issue.title}]({issue.web_url})\n"
221 section += f"> \n> TOTAL: opened: {len(od.issues)}\n\n"
222 if p.readme_url:
223 section += f"[Link to full readme]({p.readme_url})\n\n"
224 return section
225
[docs]
226 def write_files(
227 self, overview_data: List[OverviewData], base_dir: str = "quarto"
228 ) -> None:
229 """
230 Write rendered output to .qmd files as specified.
231 Implements Renderer.write_files().
232 See /specs/spec_renderer_quarto.md §6 for file output requirements.
233 """
234 # Write index.qmd
235 index_path = Path(base_dir) / "index.qmd"
236 index_content = self.render_index(overview_data, base_dir)
237 index_path.parent.mkdir(parents=True, exist_ok=True)
238 index_path.write_text(index_content, encoding="utf-8")
239
240 # Dynamically determine all groups/categories from overview_data
241 group_map = {}
242 for od in overview_data:
243 group_name = od.group.name
244 group_map.setdefault(group_name, []).append(od)
245
246 # Write per-project files in group-based subdirectories
247 for group_name, ods in group_map.items():
248 # Use group.path or group.path_with_namespace for directory, fallback to sanitized group name
249 group_obj = ods[0].group
250 group_dir_name = (
251 getattr(group_obj, "path", None)
252 or getattr(group_obj, "path_with_namespace", None)
253 or group_obj.name.replace(" ", "-").replace("/", "-")
254 )
255 group_dir = Path(base_dir) / group_dir_name
256 group_dir.mkdir(parents=True, exist_ok=True)
257 for od in ods:
258 # Use project.path or project.path_with_namespace for file name, fallback to sanitized project name
259 p = od.project
260 pname = (
261 getattr(p, "path", None)
262 or getattr(p, "path_with_namespace", None)
263 or p.name.replace(" ", "-").replace("/", "-")
264 )
265 file_path = group_dir / f"{pname}.qmd"
266 content = self.render_project_qmd(od, group_dir_name)
267 file_path.write_text(content, encoding="utf-8")
268
[docs]
269 def render_project_qmd(self, od: OverviewData, group_dir_name: str) -> str:
270 """Render a per-project .qmd file with full frontmatter and content."""
271 p = od.project
272 g = od.group
273 # --- YAML frontmatter for file ---
274 frontmatter = {
275 "title": f"{p.path_with_namespace or p.name} ({g.name})",
276 "format": {
277 "confluence-html": {
278 "toc": True,
279 "number-sections": False,
280 "html-math-method": "mathjax",
281 }
282 },
283 "categories": [g.name],
284 "tags": [],
285 }
286
287 # Add tags from extra data if available
288 tags_val = (
289 od.extra.raw_frontmatter.get("tags") if od.extra.raw_frontmatter else None
290 )
291 if tags_val:
292 frontmatter["tags"] = tags_val
293
294 # Add description from frontmatter if available
295 if (
296 od.readme
297 and od.readme.extra.raw_frontmatter
298 and "description" in od.readme.extra.raw_frontmatter
299 ):
300 frontmatter["description"] = od.readme.extra.raw_frontmatter["description"]
301
302 # --- Content section ---
303 content = f"---\n{dump_yaml(frontmatter)}---\n\n"
304
305 # Add project details
306 details = {}
307 # Add Date field
308 date_val = (
309 od.extra.raw_frontmatter.get("date") if od.extra.raw_frontmatter else None
310 )
311 if p.last_activity_at:
312 details["Date"] = p.last_activity_at.date()
313 elif date_val and date_val != "-":
314 details["Date"] = date_val
315
316 status = (
317 od.extra.raw_frontmatter.get("status")
318 if "status" in od.extra.raw_frontmatter
319 else getattr(p, "status", None)
320 )
321 if status and status != "-":
322 details["Status"] = status
323
324 supervisors = (
325 "; ".join(od.extra.supervisors)
326 if od.extra.supervisors
327 else (
328 "Authors: " + "; ".join(od.extra.authors) if od.extra.authors else None
329 )
330 )
331 if supervisors and supervisors != "-":
332 details["Supervisors"] = supervisors
333
334 if details:
335 for key, value in details.items():
336 if isinstance(value, list):
337 content += f"{key}\n"
338 for item in value:
339 content += f": {item}\n"
340 content += "\n"
341 else:
342 content += f"{key}\n: {value}\n\n"
343
344 # Add badges
345 if p.web_url:
346 content += f"[]({p.web_url}/-/releases)\n\n"
347
348 # Add description callout
349 if (
350 od.readme
351 and od.readme.extra.raw_frontmatter
352 and "description" in od.readme.extra.raw_frontmatter
353 ):
354 description_content = od.readme.extra.raw_frontmatter["description"]
355 content += f"> [!tip] Description\n> \n> {description_content}\n\n"
356 elif od.readme and od.readme.content and od.readme.content.strip():
357 # Use parse_readme to strip YAML front-matter
358 parsed = parse_readme(od.readme.content)
359 cleaned_content = parsed["content"]
360 # Truncate to first 10 non-empty lines
361 lines = [
362 "> " + line for line in cleaned_content.split("\n") if line.strip()
363 ]
364 truncated = lines[:10]
365 truncated_content = "\n".join(truncated)
366 if len(lines) > 10:
367 truncated_content += "\n> …continues…"
368 content += f"> [!warning] Description\n> \n> {truncated_content}\n\n"
369 elif not od.readme or not od.readme.content:
370 content += "> [!danger] Description\n> \n> No Readme found\n\n"
371
372 # Add issues callout
373 issues = od.issues or []
374 if issues:
375 states = {}
376 callout = ["> [!tip] Open Issues", "> ", "> "]
377 for issue in issues:
378 states[issue.state] = states.get(issue.state, 0) + 1
379 if getattr(issue, "state", None) == "opened":
380 issue_url = getattr(issue, "web_url", None) or "None"
381 callout.append(f"> - [{issue.title}]({issue_url})")
382 if "opened" not in states:
383 callout.append("> No issues open.")
384 totals_str = "; ".join([f"{s}: {n}" for s, n in sorted(states.items())])
385 callout.append("> ")
386 callout.append(f"> TOTAL: {totals_str}")
387 content += "\n".join(callout) + "\n\n"
388 elif od.readme and od.readme.todo and od.readme.todo.strip():
389 content += f"> [!warning] Open Issues\n> \n> {od.readme.todo}\n\n"
390 else:
391 content += "> [!danger] Open Issues\n> \n> No TODOs found.\n\n"
392
393 # Add link to full readme
394 if p.readme_url:
395 content += f"[Link to full readme]({p.readme_url})\n\n"
396
397 return content
398
399
400__all__ = ["QuartoRenderer"]