1"""
2Markdown renderer implementation.
3
4Implements :any:`/specs/spec_renderer_markdown` §1-4, covering:
5
6* Top-level layout (§1)
7* Summary tables (§2)
8* Project detail sections (§3)
9* Ordering guarantees (§4)
10"""
11
12from typing import List
13
14from markdown_it import MarkdownIt
15from mdit_py_plugins.front_matter import front_matter_plugin
16
17from ..models.overview_data import OverviewData
18from ..services.readme_extraction import parse_readme
19from ..rendering.table import render_table
20from ..services.sort_utils import sort_overview
21from .renderer_base import Renderer
22
23
[docs]
24class MarkdownRenderer(Renderer):
25 """
26 Markdown renderer implementation as a class.
27 Implements /specs/spec_renderer_markdown.md §6 interface.
28 """
29
[docs]
30 def __init__(self, table_config: dict):
31 self.table_config = table_config
32
[docs]
33 def write_files(
34 self,
35 overview_data: List[OverviewData],
36 output_file: str = "Overview.md",
37 *args,
38 **kwargs,
39 ) -> None:
40 """
41 Write the rendered markdown output to Overview.md.
42 See /specs/spec_renderer_markdown.md §6 for file output requirements.
43 """
44 output = self.render(overview_data)
45 with open(output_file, "w", encoding="utf-8") as f:
46 f.write(output)
47
[docs]
48 def render(self, overview_data: List[OverviewData], *args, **kwargs) -> str:
49 """
50 Render the grouped markdown overview.
51 See /specs/spec_renderer_markdown.md §6 for required output.
52 """
53
54 # Sort the overview data according to table_config
55 overview_data = sort_overview(overview_data, self.table_config)
56
57 out = "# Overview over GitLab Readmes\n\n"
58 # Group data by group name
59 group_map = {}
60 for od in overview_data:
61 group_map.setdefault(od.group.name, []).append(od)
62
63 # Sort groups by number of projects
64 sorted_groups = sorted(
65 group_map.items(), key=lambda item: (-len(item[1]), item[0].lower())
66 )
67
68 # --- 1. Summary Section ---
69 out += "## Summary\n\n"
70 for group_name, ods in sorted_groups:
71 out += f"### {group_name}\n\n"
72
73 # Prepare data for table rendering
74 table_data = []
75 for od in ods:
76 p = od.project
77 row = od.model_dump()
78
79 # Use absolute URL for repo link
80 if p.web_url:
81 row["repo"] = f"[{p.name}]({p.web_url})"
82 if p.avatar_url:
83 row["repo"] = (
84 f"{{width=16px}} {row['repo']}"
85 )
86 else:
87 row["repo"] = p.name
88 table_data.append((p.name, row))
89
90 out += render_table(table_data, self.table_config)
91 out += "\n"
92 out += "Note: If no supervisors were found, authors of the README are named as supervisors.\n\n"
93
94 # --- 2. Detailed Section ---
95 for group_name, ods in sorted_groups:
96 out += f"## Group {group_name}\n\n"
97 for od in sorted(ods, key=lambda p: p.project.name.lower()):
98 out += self.render_project_section(od)
99 return out
100
[docs]
101 def render_project_section(self, od: OverviewData) -> str:
102 """Render a detailed section for a project"""
103 p = od.project
104 section = f"### {p.name}\n\n"
105
106 details = {}
107 if (
108 "date" in od.extra.raw_frontmatter
109 and od.extra.raw_frontmatter["date"] != "-"
110 ):
111 details["Date"] = od.extra.raw_frontmatter["date"]
112
113 status = (
114 od.extra.raw_frontmatter.get("status")
115 if "status" in od.extra.raw_frontmatter
116 else getattr(p, "status", None)
117 )
118 if status and status != "-":
119 details["Status"] = status
120
121 supervisors = (
122 "; ".join(od.extra.supervisors)
123 if od.extra.supervisors
124 else (
125 "Authors: " + "; ".join(od.extra.authors) if od.extra.authors else None
126 )
127 )
128 if supervisors and supervisors != "-":
129 details["Supervisors"] = supervisors
130
131 if details:
132 for key, value in details.items():
133 if isinstance(value, list):
134 section += f"{key}\n"
135 for item in value:
136 section += f": {item}\n"
137 section += "\n"
138 else:
139 section += f"{key}\n: {value}\n\n"
140
141 if p.web_url:
142 section += f"[]({p.web_url}/-/releases)\n\n"
143
144 # Add callouts (tip, note, etc.)
145 if (
146 od.readme
147 and od.readme.extra.raw_frontmatter
148 and "description" in od.readme.extra.raw_frontmatter
149 ):
150 section += "> [!tip] Description\n>\n"
151 section += f"> {od.readme.extra.raw_frontmatter['description']}\n\n"
152 elif od.readme and od.readme.content and od.readme.content.strip():
153 # Use parse_readme to strip YAML front-matter
154 parsed = parse_readme(od.readme.content)
155 # Ensure parsed is a string (not a dict)
156 if isinstance(parsed, dict):
157 cleaned_content = parsed.get("content", "")
158 else:
159 cleaned_content = parsed
160 lines = [
161 "> " + line for line in cleaned_content.splitlines() if line.strip()
162 ]
163 truncated_content = "\n".join(lines[:10])
164 if len(lines) > 10:
165 truncated_content += "\n> …continues…"
166 section += f"> [!warning] Description\n>\n> {truncated_content}\n\n"
167 elif not od.readme or not od.readme.content:
168 section += "> [!danger] Description\n>\n> No Readme found\n\n"
169
170 # Issues summary callout logic
171 if od.issues:
172 section += f"> [!tip] Open Issues \n> \n"
173 for issue in od.issues:
174 section += f"> - [{issue.title}]({issue.web_url})\n"
175 section += f"> \n> TOTAL: opened: {len(od.issues)}\n\n"
176 elif (
177 od.readme
178 and getattr(od.readme, "todo", None)
179 and str(od.readme.todo).strip()
180 ):
181 todos = ["> " + line for line in str(od.readme.todo).strip().split("\n")]
182 todo_readme = "\n".join(todos[:10])
183 if len(todos) > 10:
184 todo_readme += "> …continues…"
185
186 section += f"> [!warning] Open Issues\n> \n> {todo_readme}\n\n"
187 else:
188 section += "> [!danger] Open Issues\n> \n> No TODOs found.\n\n"
189
190 if p.readme_url:
191 section += f"[Link to full readme]({p.readme_url})\n\n"
192 return section
193
[docs]
194 def render_summary_table(self, overview_data: list) -> str:
195 """
196 Render the summary table for the grouped overview data using the default render_table logic (no rowmod), after preprocessing with sort_overview and using od.model_dump().
197 """
198 # Preprocess and sort overview data as in Quarto
199 sorted_data = sort_overview(overview_data, self.table_config)
200 table_data = []
201 for od in sorted_data:
202 p = od.project
203 row = od.model_dump() # Use model_dump to preserve all legacy field logic
204 group_dir_name = od.group.path or od.group.name.replace(" ", "-").replace(
205 "/", "-"
206 )
207 pname = p.path or p.name.replace(" ", "-").replace("/", "-")
208 link = f"./{group_dir_name}/{pname}.md"
209 row["repo"] = f"[{p.name}]({link})"
210 if p.avatar_url:
211 row["repo"] = f"{{width=16px}} {row['repo']}"
212 table_data.append((p.name, row))
213 return render_table(table_data, self.table_config)
214
215
216__all__ = ["MarkdownRenderer"]