Source code for gitlab_overviewer.rendering.quarto

  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"![Avatar]({p.avatar_url}){{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"[![Release]({p.web_url}/-/badges/release.svg)]({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"[![Release]({p.web_url}/-/badges/release.svg)]({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"]