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
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-27 10:03 +0000
1"""Output format plugins for MiniBook.
3This module provides a plugin system for generating output in different formats.
4Each plugin implements the OutputPlugin interface to provide consistent output generation.
5"""
7import json
8import secrets
9from abc import ABC, abstractmethod
10from pathlib import Path
11from typing import Any
13from minibook.main import get_git_repo_url
14from minibook.utils import get_timestamp, load_template
16try:
17 from fpdf import FPDF
18except ImportError: # pragma: no cover
19 FPDF = None # pragma: no cover
21try:
22 from ebooklib import epub
23except ImportError: # pragma: no cover
24 epub = None # pragma: no cover
27class OutputPlugin(ABC):
28 """Base class for output format plugins.
30 All output plugins must implement the generate() method to produce
31 output in their specific format.
32 """
34 name: str = "base"
35 extension: str = ".txt"
36 description: str = "Base output plugin"
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.
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
56 Returns:
57 str: Path to the generated output file
58 """
61class HTMLPlugin(OutputPlugin):
62 """HTML output plugin using Jinja2 templates."""
64 name = "html"
65 extension = ".html"
66 description = "Generate HTML output with Tailwind CSS styling"
68 def __init__(self, template_path: str | None = None):
69 """Initialize the HTML plugin.
71 Args:
72 template_path: Optional path to a custom Jinja2 template
73 """
74 self.template_path = template_path
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.
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)
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)
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 )
109 output_path = Path(output_file)
110 with output_path.open("w") as f:
111 f.write(html)
113 return str(output_path)
116class MarkdownPlugin(OutputPlugin):
117 """Markdown output plugin."""
119 name = "markdown"
120 extension = ".md"
121 description = "Generate Markdown output"
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.
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
140 Returns:
141 str: Path to the generated Markdown file
142 """
143 lines = [f"# {title}", ""]
145 if subtitle:
146 lines.extend([f"*{subtitle}*", ""])
148 lines.append("## Links")
149 lines.append("")
151 for name, url in links:
152 lines.append(f"- [{name}]({url})")
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("")
161 content = "\n".join(lines)
163 output_path = Path(output_file)
164 with output_path.open("w") as f:
165 f.write(content)
167 return str(output_path)
170class JSONPlugin(OutputPlugin):
171 """JSON output plugin."""
173 name = "json"
174 extension = ".json"
175 description = "Generate JSON output"
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.
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
194 Returns:
195 str: Path to the generated JSON file
196 """
197 timestamp = get_timestamp()
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 }
210 content = json.dumps(data, indent=2)
212 output_path = Path(output_file)
213 with output_path.open("w") as f:
214 f.write(content)
216 return str(output_path)
219class PDFPlugin(OutputPlugin):
220 """PDF output plugin using fpdf2."""
222 name = "pdf"
223 extension = ".pdf"
224 description = "Generate PDF output"
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.
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
243 Returns:
244 str: Path to the generated PDF file
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
252 timestamp = get_timestamp()
254 pdf = FPDF()
255 pdf.add_page()
256 pdf.set_auto_page_break(auto=True, margin=15)
258 # Title
259 pdf.set_font("Helvetica", "B", 24)
260 pdf.cell(0, 15, title, new_x="LMARGIN", new_y="NEXT", align="C")
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)
269 pdf.ln(10)
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)
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)
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")
296 output_path = Path(output_file)
297 pdf.output(str(output_path))
299 return str(output_path)
302class RSTPlugin(OutputPlugin):
303 """reStructuredText output plugin."""
305 name = "rst"
306 extension = ".rst"
307 description = "Generate reStructuredText output"
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.
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
326 Returns:
327 str: Path to the generated RST file
328 """
329 timestamp = get_timestamp()
331 lines = []
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, ""])
337 if subtitle:
338 lines.extend([f"*{subtitle}*", ""])
340 # Links section
341 lines.extend(["Links", "-----", ""])
343 for name, url in links:
344 # RST link format: `Link Text <URL>`_
345 lines.append(f"* `{name} <{url}>`_")
347 lines.extend(["", "----", ""])
349 # Footer
350 lines.append(f"*Generated by* `MiniBook <https://pypi.org/project/minibook/>`_ *on {timestamp}*")
351 lines.append("")
353 content = "\n".join(lines)
355 output_path = Path(output_file)
356 with output_path.open("w") as f:
357 f.write(content)
359 return str(output_path)
362class EPUBPlugin(OutputPlugin):
363 """EPUB output plugin using ebooklib."""
365 name = "epub"
366 extension = ".epub"
367 description = "Generate EPUB ebook output"
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.
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)
386 Returns:
387 str: Path to the generated EPUB file
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
395 timestamp = get_timestamp()
396 author = kwargs.get("author", "MiniBook")
397 language = kwargs.get("language", "en")
399 # Create EPUB book
400 book = epub.EpubBook()
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)
408 # Create content chapter
409 chapter = epub.EpubHtml(title="Links", file_name="links.xhtml", lang=language)
411 # Build HTML content for the chapter
412 # Note: ebooklib requires proper XHTML content
413 html_parts = [
414 f"<h1>{title}</h1>",
415 ]
417 if subtitle:
418 html_parts.append(f'<p class="subtitle">{subtitle}</p>')
420 html_parts.append("<h2>Links</h2>")
421 html_parts.append("<ul>")
423 for name, url in links:
424 html_parts.append(f'<li><a href="{url}">{name}</a></li>')
426 html_parts.append("</ul>")
427 html_parts.append(f'<p class="footer">Generated by MiniBook on {timestamp}</p>')
429 chapter.content = "\n".join(html_parts)
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)
448 # Add chapter to book
449 book.add_item(chapter)
451 # Add navigation
452 book.toc = [epub.Link("links.xhtml", "Links", "links")]
453 book.add_item(epub.EpubNcx())
454 book.add_item(epub.EpubNav())
456 # Set spine
457 book.spine = ["nav", chapter]
459 output_path = Path(output_file)
460 epub.write_epub(str(output_path), book)
462 return str(output_path)
465class AsciiDocPlugin(OutputPlugin):
466 """AsciiDoc output plugin."""
468 name = "asciidoc"
469 extension = ".adoc"
470 description = "Generate AsciiDoc output"
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.
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
489 Returns:
490 str: Path to the generated AsciiDoc file
491 """
492 timestamp = get_timestamp()
494 lines = []
496 # AsciiDoc document header
497 lines.append(f"= {title}")
498 lines.append(":toc:")
499 lines.append(":icons: font")
500 lines.append("")
502 if subtitle:
503 lines.append(f"_{subtitle}_")
504 lines.append("")
506 # Links section
507 lines.append("== Links")
508 lines.append("")
510 for name, url in links:
511 # AsciiDoc link format: link:URL[Text]
512 lines.append(f"* link:{url}[{name}]")
514 lines.append("")
515 lines.append("'''")
516 lines.append("")
518 # Footer
519 lines.append(f"_Generated by link:https://pypi.org/project/minibook/[MiniBook] on {timestamp}_")
520 lines.append("")
522 content = "\n".join(lines)
524 output_path = Path(output_file)
525 with output_path.open("w") as f:
526 f.write(content)
528 return str(output_path)
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}
546def get_plugin(name: str) -> type[OutputPlugin]:
547 """Get an output plugin by name.
549 Args:
550 name: The name of the plugin (e.g., "html", "markdown", "json")
552 Returns:
553 The plugin class
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]
566def list_plugins() -> list[dict[str, str]]:
567 """List all available output plugins.
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