Coverage for src / minibook / plugins.py: 100%

208 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-01-27 10:03 +0000

1"""Output format plugins for MiniBook. 

2 

3This module provides a plugin system for generating output in different formats. 

4Each plugin implements the OutputPlugin interface to provide consistent output generation. 

5""" 

6 

7import json 

8import secrets 

9from abc import ABC, abstractmethod 

10from pathlib import Path 

11from typing import Any 

12 

13from minibook.main import get_git_repo_url 

14from minibook.utils import get_timestamp, load_template 

15 

16try: 

17 from fpdf import FPDF 

18except ImportError: # pragma: no cover 

19 FPDF = None # pragma: no cover 

20 

21try: 

22 from ebooklib import epub 

23except ImportError: # pragma: no cover 

24 epub = None # pragma: no cover 

25 

26 

27class OutputPlugin(ABC): 

28 """Base class for output format plugins. 

29 

30 All output plugins must implement the generate() method to produce 

31 output in their specific format. 

32 """ 

33 

34 name: str = "base" 

35 extension: str = ".txt" 

36 description: str = "Base output plugin" 

37 

38 @abstractmethod 

39 def generate( 

40 self, 

41 title: str, 

42 links: list[tuple[str, str]], 

43 subtitle: str | None = None, 

44 output_file: str | Path = "output", 

45 **kwargs: Any, 

46 ) -> str: 

47 """Generate output in the plugin's format. 

48 

49 Args: 

50 title: The title of the document 

51 links: List of (name, url) tuples 

52 subtitle: Optional subtitle/description 

53 output_file: Path to the output file 

54 **kwargs: Additional format-specific options 

55 

56 Returns: 

57 str: Path to the generated output file 

58 """ 

59 

60 

61class HTMLPlugin(OutputPlugin): 

62 """HTML output plugin using Jinja2 templates.""" 

63 

64 name = "html" 

65 extension = ".html" 

66 description = "Generate HTML output with Tailwind CSS styling" 

67 

68 def __init__(self, template_path: str | None = None): 

69 """Initialize the HTML plugin. 

70 

71 Args: 

72 template_path: Optional path to a custom Jinja2 template 

73 """ 

74 self.template_path = template_path 

75 

76 def generate( 

77 self, 

78 title: str, 

79 links: list[tuple[str, str]], 

80 subtitle: str | None = None, 

81 output_file: str | Path = "index.html", 

82 **kwargs: Any, 

83 ) -> str: 

84 """Generate HTML output. 

85 

86 Args: 

87 title: The title of the webpage 

88 links: List of (name, url) tuples 

89 subtitle: Optional description 

90 output_file: Path to the output HTML file 

91 **kwargs: Additional options (nonce for CSP) 

92 

93 Returns: 

94 str: Path to the generated HTML file 

95 """ 

96 template = load_template(self.template_path) 

97 timestamp = get_timestamp() 

98 nonce = kwargs.get("nonce") or secrets.token_urlsafe(16) 

99 

100 html = template.render( 

101 title=title, 

102 links=links, 

103 description=subtitle, 

104 timestamp=timestamp, 

105 repository_url=get_git_repo_url(), 

106 nonce=nonce, 

107 ) 

108 

109 output_path = Path(output_file) 

110 with output_path.open("w") as f: 

111 f.write(html) 

112 

113 return str(output_path) 

114 

115 

116class MarkdownPlugin(OutputPlugin): 

117 """Markdown output plugin.""" 

118 

119 name = "markdown" 

120 extension = ".md" 

121 description = "Generate Markdown output" 

122 

123 def generate( 

124 self, 

125 title: str, 

126 links: list[tuple[str, str]], 

127 subtitle: str | None = None, 

128 output_file: str | Path = "links.md", 

129 **kwargs: Any, 

130 ) -> str: 

131 """Generate Markdown output. 

132 

133 Args: 

134 title: The title of the document 

135 links: List of (name, url) tuples 

136 subtitle: Optional description 

137 output_file: Path to the output Markdown file 

138 **kwargs: Additional options 

139 

140 Returns: 

141 str: Path to the generated Markdown file 

142 """ 

143 lines = [f"# {title}", ""] 

144 

145 if subtitle: 

146 lines.extend([f"*{subtitle}*", ""]) 

147 

148 lines.append("## Links") 

149 lines.append("") 

150 

151 for name, url in links: 

152 lines.append(f"- [{name}]({url})") 

153 

154 lines.append("") 

155 lines.append("---") 

156 lines.append("") 

157 timestamp = get_timestamp() 

158 lines.append(f"*Generated by [MiniBook](https://pypi.org/project/minibook/) on {timestamp}*") 

159 lines.append("") 

160 

161 content = "\n".join(lines) 

162 

163 output_path = Path(output_file) 

164 with output_path.open("w") as f: 

165 f.write(content) 

166 

167 return str(output_path) 

168 

169 

170class JSONPlugin(OutputPlugin): 

171 """JSON output plugin.""" 

172 

173 name = "json" 

174 extension = ".json" 

175 description = "Generate JSON output" 

176 

177 def generate( 

178 self, 

179 title: str, 

180 links: list[tuple[str, str]], 

181 subtitle: str | None = None, 

182 output_file: str | Path = "links.json", 

183 **kwargs: Any, 

184 ) -> str: 

185 """Generate JSON output. 

186 

187 Args: 

188 title: The title of the document 

189 links: List of (name, url) tuples 

190 subtitle: Optional description 

191 output_file: Path to the output JSON file 

192 **kwargs: Additional options 

193 

194 Returns: 

195 str: Path to the generated JSON file 

196 """ 

197 timestamp = get_timestamp() 

198 

199 data = { 

200 "title": title, 

201 "description": subtitle, 

202 "links": [{"name": name, "url": url} for name, url in links], 

203 "metadata": { 

204 "generated_by": "MiniBook", 

205 "timestamp": timestamp, 

206 "repository_url": get_git_repo_url(), 

207 }, 

208 } 

209 

210 content = json.dumps(data, indent=2) 

211 

212 output_path = Path(output_file) 

213 with output_path.open("w") as f: 

214 f.write(content) 

215 

216 return str(output_path) 

217 

218 

219class PDFPlugin(OutputPlugin): 

220 """PDF output plugin using fpdf2.""" 

221 

222 name = "pdf" 

223 extension = ".pdf" 

224 description = "Generate PDF output" 

225 

226 def generate( 

227 self, 

228 title: str, 

229 links: list[tuple[str, str]], 

230 subtitle: str | None = None, 

231 output_file: str | Path = "links.pdf", 

232 **kwargs: Any, 

233 ) -> str: 

234 """Generate PDF output. 

235 

236 Args: 

237 title: The title of the document 

238 links: List of (name, url) tuples 

239 subtitle: Optional description 

240 output_file: Path to the output PDF file 

241 **kwargs: Additional options 

242 

243 Returns: 

244 str: Path to the generated PDF file 

245 

246 Raises: 

247 ImportError: If fpdf2 is not installed 

248 """ 

249 if FPDF is None: 

250 raise ImportError("PDF generation requires fpdf2. Install with: uv add fpdf2") # noqa: TRY003 

251 

252 timestamp = get_timestamp() 

253 

254 pdf = FPDF() 

255 pdf.add_page() 

256 pdf.set_auto_page_break(auto=True, margin=15) 

257 

258 # Title 

259 pdf.set_font("Helvetica", "B", 24) 

260 pdf.cell(0, 15, title, new_x="LMARGIN", new_y="NEXT", align="C") 

261 

262 # Subtitle 

263 if subtitle: 

264 pdf.set_font("Helvetica", "I", 12) 

265 pdf.set_text_color(100, 100, 100) 

266 pdf.cell(0, 10, subtitle, new_x="LMARGIN", new_y="NEXT", align="C") 

267 pdf.set_text_color(0, 0, 0) 

268 

269 pdf.ln(10) 

270 

271 # Links section header 

272 pdf.set_font("Helvetica", "B", 16) 

273 pdf.cell(0, 10, "Links", new_x="LMARGIN", new_y="NEXT") 

274 pdf.ln(5) 

275 

276 # Links 

277 pdf.set_font("Helvetica", "", 11) 

278 for name, url in links: 

279 # Link name in bold 

280 pdf.set_font("Helvetica", "B", 11) 

281 pdf.cell(0, 8, f"- {name}", new_x="LMARGIN", new_y="NEXT") 

282 # URL in blue, smaller 

283 pdf.set_font("Helvetica", "", 9) 

284 pdf.set_text_color(0, 0, 200) 

285 pdf.cell(0, 6, f" {url}", new_x="LMARGIN", new_y="NEXT", link=url) 

286 pdf.set_text_color(0, 0, 0) 

287 pdf.ln(2) 

288 

289 # Footer 

290 pdf.ln(15) 

291 pdf.set_font("Helvetica", "I", 9) 

292 pdf.set_text_color(128, 128, 128) 

293 pdf.cell(0, 8, f"Generated by MiniBook on {timestamp}", new_x="LMARGIN", new_y="NEXT", align="C") 

294 pdf.cell(0, 6, "https://pypi.org/project/minibook/", new_x="LMARGIN", new_y="NEXT", align="C") 

295 

296 output_path = Path(output_file) 

297 pdf.output(str(output_path)) 

298 

299 return str(output_path) 

300 

301 

302class RSTPlugin(OutputPlugin): 

303 """reStructuredText output plugin.""" 

304 

305 name = "rst" 

306 extension = ".rst" 

307 description = "Generate reStructuredText output" 

308 

309 def generate( 

310 self, 

311 title: str, 

312 links: list[tuple[str, str]], 

313 subtitle: str | None = None, 

314 output_file: str | Path = "links.rst", 

315 **kwargs: Any, 

316 ) -> str: 

317 """Generate reStructuredText output. 

318 

319 Args: 

320 title: The title of the document 

321 links: List of (name, url) tuples 

322 subtitle: Optional description 

323 output_file: Path to the output RST file 

324 **kwargs: Additional options 

325 

326 Returns: 

327 str: Path to the generated RST file 

328 """ 

329 timestamp = get_timestamp() 

330 

331 lines = [] 

332 

333 # Title with RST underline (must be at least as long as title) 

334 title_underline = "=" * len(title) 

335 lines.extend([title_underline, title, title_underline, ""]) 

336 

337 if subtitle: 

338 lines.extend([f"*{subtitle}*", ""]) 

339 

340 # Links section 

341 lines.extend(["Links", "-----", ""]) 

342 

343 for name, url in links: 

344 # RST link format: `Link Text <URL>`_ 

345 lines.append(f"* `{name} <{url}>`_") 

346 

347 lines.extend(["", "----", ""]) 

348 

349 # Footer 

350 lines.append(f"*Generated by* `MiniBook <https://pypi.org/project/minibook/>`_ *on {timestamp}*") 

351 lines.append("") 

352 

353 content = "\n".join(lines) 

354 

355 output_path = Path(output_file) 

356 with output_path.open("w") as f: 

357 f.write(content) 

358 

359 return str(output_path) 

360 

361 

362class EPUBPlugin(OutputPlugin): 

363 """EPUB output plugin using ebooklib.""" 

364 

365 name = "epub" 

366 extension = ".epub" 

367 description = "Generate EPUB ebook output" 

368 

369 def generate( 

370 self, 

371 title: str, 

372 links: list[tuple[str, str]], 

373 subtitle: str | None = None, 

374 output_file: str | Path = "links.epub", 

375 **kwargs: Any, 

376 ) -> str: 

377 """Generate EPUB output. 

378 

379 Args: 

380 title: The title of the ebook 

381 links: List of (name, url) tuples 

382 subtitle: Optional description 

383 output_file: Path to the output EPUB file 

384 **kwargs: Additional options (author, language) 

385 

386 Returns: 

387 str: Path to the generated EPUB file 

388 

389 Raises: 

390 ImportError: If ebooklib is not installed 

391 """ 

392 if epub is None: 

393 raise ImportError("EPUB generation requires ebooklib. Install with: pip install minibook[epub]") # noqa: TRY003 

394 

395 timestamp = get_timestamp() 

396 author = kwargs.get("author", "MiniBook") 

397 language = kwargs.get("language", "en") 

398 

399 # Create EPUB book 

400 book = epub.EpubBook() 

401 

402 # Set metadata 

403 book.set_identifier(f"minibook-{timestamp}") 

404 book.set_title(title) 

405 book.set_language(language) 

406 book.add_author(author) 

407 

408 # Create content chapter 

409 chapter = epub.EpubHtml(title="Links", file_name="links.xhtml", lang=language) 

410 

411 # Build HTML content for the chapter 

412 # Note: ebooklib requires proper XHTML content 

413 html_parts = [ 

414 f"<h1>{title}</h1>", 

415 ] 

416 

417 if subtitle: 

418 html_parts.append(f'<p class="subtitle">{subtitle}</p>') 

419 

420 html_parts.append("<h2>Links</h2>") 

421 html_parts.append("<ul>") 

422 

423 for name, url in links: 

424 html_parts.append(f'<li><a href="{url}">{name}</a></li>') 

425 

426 html_parts.append("</ul>") 

427 html_parts.append(f'<p class="footer">Generated by MiniBook on {timestamp}</p>') 

428 

429 chapter.content = "\n".join(html_parts) 

430 

431 # Add default CSS 

432 style = epub.EpubItem( 

433 uid="style", 

434 file_name="style.css", 

435 media_type="text/css", 

436 content=b""" 

437body { font-family: Georgia, serif; margin: 2em; } 

438h1 { color: #333; } 

439.subtitle { color: #666; font-style: italic; margin-bottom: 1.5em; } 

440ul { list-style-type: disc; } 

441li { margin: 0.5em 0; } 

442a { color: #0066cc; text-decoration: none; } 

443.footer { margin-top: 2em; font-size: 0.9em; color: #888; } 

444""", 

445 ) 

446 book.add_item(style) 

447 

448 # Add chapter to book 

449 book.add_item(chapter) 

450 

451 # Add navigation 

452 book.toc = [epub.Link("links.xhtml", "Links", "links")] 

453 book.add_item(epub.EpubNcx()) 

454 book.add_item(epub.EpubNav()) 

455 

456 # Set spine 

457 book.spine = ["nav", chapter] 

458 

459 output_path = Path(output_file) 

460 epub.write_epub(str(output_path), book) 

461 

462 return str(output_path) 

463 

464 

465class AsciiDocPlugin(OutputPlugin): 

466 """AsciiDoc output plugin.""" 

467 

468 name = "asciidoc" 

469 extension = ".adoc" 

470 description = "Generate AsciiDoc output" 

471 

472 def generate( 

473 self, 

474 title: str, 

475 links: list[tuple[str, str]], 

476 subtitle: str | None = None, 

477 output_file: str | Path = "links.adoc", 

478 **kwargs: Any, 

479 ) -> str: 

480 """Generate AsciiDoc output. 

481 

482 Args: 

483 title: The title of the document 

484 links: List of (name, url) tuples 

485 subtitle: Optional description 

486 output_file: Path to the output AsciiDoc file 

487 **kwargs: Additional options 

488 

489 Returns: 

490 str: Path to the generated AsciiDoc file 

491 """ 

492 timestamp = get_timestamp() 

493 

494 lines = [] 

495 

496 # AsciiDoc document header 

497 lines.append(f"= {title}") 

498 lines.append(":toc:") 

499 lines.append(":icons: font") 

500 lines.append("") 

501 

502 if subtitle: 

503 lines.append(f"_{subtitle}_") 

504 lines.append("") 

505 

506 # Links section 

507 lines.append("== Links") 

508 lines.append("") 

509 

510 for name, url in links: 

511 # AsciiDoc link format: link:URL[Text] 

512 lines.append(f"* link:{url}[{name}]") 

513 

514 lines.append("") 

515 lines.append("'''") 

516 lines.append("") 

517 

518 # Footer 

519 lines.append(f"_Generated by link:https://pypi.org/project/minibook/[MiniBook] on {timestamp}_") 

520 lines.append("") 

521 

522 content = "\n".join(lines) 

523 

524 output_path = Path(output_file) 

525 with output_path.open("w") as f: 

526 f.write(content) 

527 

528 return str(output_path) 

529 

530 

531# Registry of available plugins 

532PLUGINS: dict[str, type[OutputPlugin]] = { 

533 "html": HTMLPlugin, 

534 "markdown": MarkdownPlugin, 

535 "md": MarkdownPlugin, # Alias 

536 "json": JSONPlugin, 

537 "pdf": PDFPlugin, 

538 "rst": RSTPlugin, 

539 "restructuredtext": RSTPlugin, # Alias 

540 "epub": EPUBPlugin, 

541 "asciidoc": AsciiDocPlugin, 

542 "adoc": AsciiDocPlugin, # Alias 

543} 

544 

545 

546def get_plugin(name: str) -> type[OutputPlugin]: 

547 """Get an output plugin by name. 

548 

549 Args: 

550 name: The name of the plugin (e.g., "html", "markdown", "json") 

551 

552 Returns: 

553 The plugin class 

554 

555 Raises: 

556 ValueError: If the plugin name is not recognized 

557 """ 

558 name_lower = name.lower() 

559 if name_lower not in PLUGINS: 

560 available = ", ".join(sorted(set(PLUGINS.keys()))) 

561 msg = f"Unknown output format '{name}'. Available formats: {available}" 

562 raise ValueError(msg) 

563 return PLUGINS[name_lower] 

564 

565 

566def list_plugins() -> list[dict[str, str]]: 

567 """List all available output plugins. 

568 

569 Returns: 

570 List of dicts with plugin info (name, extension, description) 

571 """ 

572 seen = set() 

573 result = [] 

574 for _name, plugin_cls in PLUGINS.items(): 

575 if plugin_cls.name not in seen: 

576 seen.add(plugin_cls.name) 

577 result.append( 

578 { 

579 "name": plugin_cls.name, 

580 "extension": plugin_cls.extension, 

581 "description": plugin_cls.description, 

582 } 

583 ) 

584 return result