mddoco 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mddoco/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
mddoco/cli.py ADDED
@@ -0,0 +1,92 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+
5
+ from mddoco.converter import convert_files
6
+ from mddoco.pdf import html_to_pdf
7
+ from mddoco.renderer import render_html
8
+ from mddoco.scanner import find_markdown_files
9
+ from mddoco.toc import combine_toc
10
+ from mddoco.writer import write_output
11
+
12
+
13
+ @click.command()
14
+ @click.argument("input_path", type=click.Path(exists=True, path_type=Path))
15
+ @click.option(
16
+ "--output", "-o", "output_path",
17
+ default=".", show_default=True,
18
+ type=click.Path(file_okay=False, path_type=Path),
19
+ help="Directory to write the output file.",
20
+ )
21
+ @click.option(
22
+ "--format", "-f", "fmt",
23
+ default="html", show_default=True,
24
+ type=click.Choice(["html", "pdf"], case_sensitive=False),
25
+ help="Output format.",
26
+ )
27
+ @click.option("--title", "-t", default=None, help="Document title.")
28
+ @click.option(
29
+ "--theme",
30
+ default="default", show_default=True,
31
+ help="Theme name to use for rendering.",
32
+ )
33
+ @click.option(
34
+ "--toc/--no-toc",
35
+ default=False, show_default=True,
36
+ help="Generate a table of contents.",
37
+ )
38
+ @click.option(
39
+ "--toc-depth",
40
+ default=3, show_default=True,
41
+ type=click.IntRange(1, 6),
42
+ help="Maximum heading depth included in the TOC.",
43
+ )
44
+ def main(
45
+ input_path: Path,
46
+ output_path: Path,
47
+ fmt: str,
48
+ title: str | None,
49
+ theme: str,
50
+ toc: bool,
51
+ toc_depth: int,
52
+ ) -> None:
53
+ """Convert markdown files in INPUT_PATH to a single document."""
54
+ try:
55
+ md_files = find_markdown_files(input_path)
56
+ except (ValueError, FileNotFoundError) as exc:
57
+ raise click.ClickException(str(exc)) from exc
58
+
59
+ click.echo(f"Found {len(md_files)} markdown file(s).")
60
+
61
+ raw = convert_files(md_files, toc=toc, toc_depth=toc_depth)
62
+ sections = [(p, html) for p, html, _ in raw]
63
+ toc_html = combine_toc([t for _, _, t in raw]) if toc else None
64
+ has_mermaid = any('<div class="mermaid">' in html for _, html in sections)
65
+
66
+ resolved = input_path.resolve()
67
+ stem = resolved.stem if resolved.is_file() else resolved.name
68
+
69
+ try:
70
+ content = render_html(
71
+ sections,
72
+ title=title,
73
+ toc_html=toc_html or None,
74
+ theme=theme,
75
+ has_mermaid=has_mermaid,
76
+ )
77
+ except ValueError as exc:
78
+ raise click.ClickException(str(exc)) from exc
79
+
80
+ if fmt == "html":
81
+ output_file = write_output(content, output_path, f"{stem}.html")
82
+ click.echo(f"Written to {output_file}")
83
+
84
+ elif fmt == "pdf":
85
+ output_path.mkdir(parents=True, exist_ok=True)
86
+ dest = output_path / f"{stem}.pdf"
87
+ click.echo("Rendering PDF...")
88
+ try:
89
+ html_to_pdf(content, dest)
90
+ except RuntimeError as exc:
91
+ raise click.ClickException(str(exc)) from exc
92
+ click.echo(f"Written to {dest}")
mddoco/converter.py ADDED
@@ -0,0 +1,46 @@
1
+ from pathlib import Path
2
+
3
+ import markdown
4
+
5
+ from mddoco.graph import process_graph
6
+ from mddoco.mermaid import process_mermaid
7
+
8
+
9
+ def convert_file(
10
+ path: Path,
11
+ toc: bool = False,
12
+ toc_depth: int = 3,
13
+ ) -> tuple[str, str]:
14
+ """Convert a markdown file to an HTML fragment.
15
+
16
+ Returns (html_body, toc_html). toc_html is empty when toc=False.
17
+ """
18
+ text = path.read_text(encoding="utf-8")
19
+
20
+ extensions = ["tables", "fenced_code", "attr_list"]
21
+ extension_configs: dict = {}
22
+
23
+ if toc:
24
+ extensions.append("toc")
25
+ extension_configs["toc"] = {"toc_depth": toc_depth}
26
+
27
+ md = markdown.Markdown(extensions=extensions, extension_configs=extension_configs)
28
+ html = md.convert(text)
29
+ html, _ = process_mermaid(html)
30
+ html, _ = process_graph(html)
31
+
32
+ toc_html: str = md.toc if toc else ""
33
+ return html, toc_html
34
+
35
+
36
+ def convert_files(
37
+ paths: list[Path],
38
+ toc: bool = False,
39
+ toc_depth: int = 3,
40
+ ) -> list[tuple[Path, str, str]]:
41
+ """Convert markdown files, returning (path, html_body, toc_html) tuples."""
42
+ results = []
43
+ for p in paths:
44
+ html, toc_html = convert_file(p, toc=toc, toc_depth=toc_depth)
45
+ results.append((p, html, toc_html))
46
+ return results
mddoco/graph.py ADDED
@@ -0,0 +1,117 @@
1
+ import io
2
+ import json
3
+ import re
4
+ from html import escape, unescape
5
+
6
+ import matplotlib
7
+ matplotlib.use('Agg') # non-interactive backend — must be set before importing pyplot
8
+ import matplotlib.pyplot as plt
9
+
10
+ _DEFAULT_COLOURS = [
11
+ '#2d6cbe', '#e74c3c', '#27ae60', '#e67e22',
12
+ '#8e44ad', '#16a085', '#2c3e50', '#d35400',
13
+ ]
14
+
15
+
16
+ def graph_it(data, output=None):
17
+ dpi = 100
18
+ width_px = data.get('width_px', 640)
19
+ height_px = data.get('height_px', 480)
20
+ fig, ax = plt.subplots(figsize=(width_px / dpi, height_px / dpi), dpi=dpi)
21
+
22
+ orientation = data.get('orientation', 'vertical').lower()
23
+ if orientation not in ('horizontal', 'vertical'):
24
+ raise ValueError(
25
+ f"Invalid orientation '{orientation}' — must be 'horizontal' or 'vertical'"
26
+ )
27
+ horizontal = orientation == 'horizontal'
28
+
29
+ x = data['data']['x']
30
+
31
+ # Auto-generate series from data keys (everything except 'x') if not provided
32
+ series_list = data.get('series') or [
33
+ {'label': k} for k in data['data'] if k != 'x'
34
+ ]
35
+
36
+ for i, series in enumerate(series_list):
37
+ label = series['label']
38
+ values = data['data'][label]
39
+ series_type = series.get('type', 'line')
40
+ colour = series.get('colour', _DEFAULT_COLOURS[i % len(_DEFAULT_COLOURS)])
41
+
42
+ if series_type == 'bar':
43
+ colour_val = colour if isinstance(colour, list) else [colour] * len(values)
44
+ if horizontal:
45
+ ax.barh(x, values, color=colour_val, label=label)
46
+ else:
47
+ ax.bar(x, values, color=colour_val, label=label)
48
+ elif series_type in ('line', 'line2'):
49
+ marker = 'o' if series.get('marker', False) else 'None'
50
+ linestyle = ':' if series_type == 'line2' else '-'
51
+ if horizontal:
52
+ ax.plot(values, x, color=colour, marker=marker, linewidth=2, linestyle=linestyle, label=label)
53
+ else:
54
+ ax.plot(x, values, color=colour, marker=marker, linewidth=2, linestyle=linestyle, label=label)
55
+
56
+ if 'min' in data or 'max' in data:
57
+ lo = data.get('min', None)
58
+ hi = data.get('max', None)
59
+ if horizontal:
60
+ ax.set_xlim(lo, hi)
61
+ else:
62
+ ax.set_ylim(lo, hi)
63
+
64
+ title = data.get('title', '')
65
+ if title:
66
+ ax.set_title(title)
67
+
68
+ if data.get('show_legend', True):
69
+ ax.legend(loc='upper left')
70
+
71
+ ax.spines['top'].set_visible(False)
72
+ ax.spines['right'].set_visible(False)
73
+
74
+ if output:
75
+ plt.savefig(output, format="svg", bbox_inches='tight')
76
+ plt.close(fig)
77
+ else:
78
+ buf = io.StringIO()
79
+ plt.savefig(buf, format="svg", bbox_inches='tight')
80
+ plt.close(fig)
81
+ return buf.getvalue()
82
+
83
+
84
+ _GRAPH_BLOCK = re.compile(
85
+ r'<pre><code class="language-graph">(.*?)</code></pre>',
86
+ re.DOTALL,
87
+ )
88
+
89
+ _SVG_HEADER = re.compile(r'^.*?(?=<svg)', re.DOTALL)
90
+
91
+
92
+ def _render_graph(source: str) -> str:
93
+ """Parse JSON source, render via graph_it, and return an inline SVG element."""
94
+ data = json.loads(unescape(source))
95
+ svg = graph_it(data)
96
+ svg = _SVG_HEADER.sub('', svg) # strip XML declaration and DOCTYPE
97
+ return f'<div class="graph">{svg}</div>'
98
+
99
+
100
+ def process_graph(html: str) -> tuple[str, bool]:
101
+ """Replace fenced graph blocks with rendered SVG charts.
102
+
103
+ Falls back to an inline error message if the block cannot be parsed or rendered.
104
+ Returns (processed_html, found).
105
+ """
106
+ found = bool(_GRAPH_BLOCK.search(html))
107
+
108
+ def replace(m: re.Match) -> str:
109
+ try:
110
+ return _render_graph(m.group(1))
111
+ except Exception as exc:
112
+ return (
113
+ f'<p class="graph-error"><strong>Graph error:</strong> '
114
+ f'{escape(str(exc))}</p>'
115
+ )
116
+
117
+ return _GRAPH_BLOCK.sub(replace, html), found
mddoco/mermaid.py ADDED
@@ -0,0 +1,20 @@
1
+ import re
2
+
3
+ _MERMAID_BLOCK = re.compile(
4
+ r'<pre><code class="language-mermaid">(.*?)</code></pre>',
5
+ re.DOTALL,
6
+ )
7
+
8
+
9
+ def process_mermaid(html: str) -> tuple[str, bool]:
10
+ """Replace fenced mermaid blocks with Mermaid.js div elements.
11
+
12
+ Returns (processed_html, found) where found indicates whether any
13
+ mermaid blocks were present.
14
+ """
15
+ found = bool(_MERMAID_BLOCK.search(html))
16
+ processed = _MERMAID_BLOCK.sub(
17
+ lambda m: f'<div class="mermaid">{m.group(1)}</div>',
18
+ html,
19
+ )
20
+ return processed, found
mddoco/pdf.py ADDED
@@ -0,0 +1,33 @@
1
+ import tempfile
2
+ from pathlib import Path
3
+
4
+
5
+ def html_to_pdf(html_content: str, dest: Path) -> None:
6
+ """Render an HTML string to a PDF file using a headless Chromium browser.
7
+
8
+ Navigates via a temporary file:// URL so that external resources (CDN
9
+ scripts, Mermaid.js, etc.) load correctly before the page is printed.
10
+ """
11
+ try:
12
+ from playwright.sync_api import sync_playwright
13
+ except ImportError:
14
+ raise RuntimeError(
15
+ "Playwright is required for PDF output. "
16
+ "Run: pip install playwright && playwright install chromium"
17
+ )
18
+
19
+ with tempfile.NamedTemporaryFile(
20
+ suffix=".html", delete=False, mode="w", encoding="utf-8"
21
+ ) as f:
22
+ f.write(html_content)
23
+ tmp_path = Path(f.name)
24
+
25
+ try:
26
+ with sync_playwright() as p:
27
+ browser = p.chromium.launch()
28
+ page = browser.new_page()
29
+ page.goto(tmp_path.as_uri(), wait_until="networkidle")
30
+ page.pdf(path=str(dest), format="A4", print_background=True)
31
+ browser.close()
32
+ finally:
33
+ tmp_path.unlink(missing_ok=True)
mddoco/renderer.py ADDED
@@ -0,0 +1,33 @@
1
+ from importlib.resources import files
2
+ from pathlib import Path
3
+
4
+ from jinja2 import Environment, FileSystemLoader, TemplateNotFound
5
+
6
+
7
+ def _make_env() -> Environment:
8
+ themes_dir = files("mddoco").joinpath("themes")
9
+ return Environment(
10
+ loader=FileSystemLoader(str(themes_dir)),
11
+ autoescape=True,
12
+ )
13
+
14
+
15
+ def render_html(
16
+ sections: list[tuple[Path, str]],
17
+ title: str | None = None,
18
+ toc_html: str | None = None,
19
+ theme: str = "default",
20
+ has_mermaid: bool = False,
21
+ ) -> str:
22
+ """Render a full HTML document from (path, html_fragment) pairs."""
23
+ env = _make_env()
24
+ try:
25
+ template = env.get_template(f"{theme}.html")
26
+ except TemplateNotFound:
27
+ raise ValueError(f"Theme '{theme}' not found.")
28
+ return template.render(
29
+ title=title,
30
+ toc_html=toc_html,
31
+ sections=sections,
32
+ has_mermaid=has_mermaid,
33
+ )
mddoco/scanner.py ADDED
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def find_markdown_files(input_path: Path) -> list[Path]:
5
+ """Return markdown files to process.
6
+
7
+ If input_path is a file, return it directly (must be a .md file).
8
+ If input_path is a directory, return all *.md files sorted by relative path.
9
+ """
10
+ if input_path.is_file():
11
+ if input_path.suffix.lower() != ".md":
12
+ raise ValueError(f"File is not a markdown file: {input_path}")
13
+ return [input_path]
14
+
15
+ if input_path.is_dir():
16
+ files = sorted(input_path.rglob("*.md"))
17
+ if not files:
18
+ raise FileNotFoundError(f"No markdown files found in: {input_path}")
19
+ return files
20
+
21
+ raise ValueError(f"Input path does not exist: {input_path}")
@@ -0,0 +1,167 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% if title %}{{ title }}{% else %}Document{% endif %}</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; }
9
+
10
+ body {
11
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
12
+ font-size: 16px;
13
+ line-height: 1.7;
14
+ color: #24292e;
15
+ margin: 0;
16
+ padding: 2rem 1rem;
17
+ background: #fff;
18
+ }
19
+
20
+ .container {
21
+ max-width: 860px;
22
+ margin: 0 auto;
23
+ }
24
+
25
+ h1, h2, h3, h4, h5, h6 {
26
+ margin-top: 1.5em;
27
+ margin-bottom: 0.5em;
28
+ font-weight: 600;
29
+ line-height: 1.25;
30
+ }
31
+ h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
32
+ h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
33
+ h3 { font-size: 1.25em; }
34
+ h4 { font-size: 1em; }
35
+
36
+ .doc-title {
37
+ font-size: 2.5em;
38
+ margin-top: 0;
39
+ border-bottom: 2px solid #24292e;
40
+ }
41
+
42
+ a { color: #0366d6; text-decoration: none; }
43
+ a:hover { text-decoration: underline; }
44
+
45
+ p { margin: 0.75em 0; }
46
+
47
+ code {
48
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
49
+ font-size: 0.875em;
50
+ background: #f6f8fa;
51
+ padding: 0.2em 0.4em;
52
+ border-radius: 3px;
53
+ }
54
+
55
+ pre {
56
+ background: #f6f8fa;
57
+ border: 1px solid #eaecef;
58
+ border-radius: 6px;
59
+ padding: 1rem;
60
+ overflow-x: auto;
61
+ line-height: 1.45;
62
+ }
63
+
64
+ pre code {
65
+ background: none;
66
+ padding: 0;
67
+ border-radius: 0;
68
+ font-size: 0.875em;
69
+ }
70
+
71
+ table {
72
+ border-collapse: collapse;
73
+ width: 100%;
74
+ margin: 1em 0;
75
+ outline: 1px solid #c8ccd0;
76
+ }
77
+ th, td {
78
+ border: 1px solid #c8ccd0;
79
+ padding: 0.5em 1em;
80
+ text-align: left;
81
+ }
82
+ th { background: #f0f3f6; font-weight: 600; }
83
+ tr:nth-child(even) td { background: #fafbfc; }
84
+
85
+ blockquote {
86
+ margin: 1em 0;
87
+ padding: 0.5em 1em;
88
+ border-left: 4px solid #dfe2e5;
89
+ color: #6a737d;
90
+ }
91
+ blockquote p { margin: 0; }
92
+
93
+ img { max-width: 100%; height: auto; }
94
+
95
+ ul, ol { padding-left: 1.5em; margin: 0.75em 0; }
96
+ li { margin: 0.25em 0; }
97
+
98
+ hr, .section-divider {
99
+ border: none;
100
+ border-top: 1px solid #eaecef;
101
+ margin: 3rem 0;
102
+ }
103
+
104
+ /* Table of Contents */
105
+ .toc-block {
106
+ background: #f6f8fa;
107
+ border: 1px solid #eaecef;
108
+ border-radius: 6px;
109
+ padding: 1rem 1.5rem;
110
+ margin: 1.5rem 0 2.5rem;
111
+ display: inline-block;
112
+ min-width: 260px;
113
+ }
114
+ .toc-block .toc-heading {
115
+ font-size: 0.75em;
116
+ font-weight: 700;
117
+ text-transform: uppercase;
118
+ letter-spacing: 0.08em;
119
+ color: #6a737d;
120
+ margin: 0 0 0.5rem;
121
+ }
122
+ .toc-block ul {
123
+ margin: 0;
124
+ padding-left: 1.25em;
125
+ list-style: none;
126
+ }
127
+ .toc-block ul ul { padding-left: 1em; }
128
+ .toc-block li { margin: 0.2em 0; }
129
+ .toc-block a { color: #0366d6; font-size: 0.9em; }
130
+
131
+ /* Mermaid diagrams */
132
+ .mermaid {
133
+ text-align: center;
134
+ margin: 1.5em 0;
135
+ }
136
+ </style>
137
+ {% if has_mermaid %}
138
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
139
+ <script>mermaid.initialize({ startOnLoad: true, theme: 'default' });</script>
140
+ {% endif %}
141
+ </head>
142
+ <body>
143
+ <div class="container">
144
+
145
+ {% if title %}
146
+ <h1 class="doc-title">{{ title }}</h1>
147
+ {% endif %}
148
+
149
+ {% if toc_html %}
150
+ <nav class="toc-block" aria-label="Table of contents">
151
+ <p class="toc-heading">Contents</p>
152
+ {{ toc_html | safe }}
153
+ </nav>
154
+ {% endif %}
155
+
156
+ <main>
157
+ {% for path, body in sections %}
158
+ {% if not loop.first %}
159
+ <hr class="section-divider">
160
+ {% endif %}
161
+ <section>{{ body | safe }}</section>
162
+ {% endfor %}
163
+ </main>
164
+
165
+ </div>
166
+ </body>
167
+ </html>
@@ -0,0 +1,230 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% if title %}{{ title }}{% else %}Document{% endif %}</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; }
9
+
10
+ :root {
11
+ --primary: #1a3a5c;
12
+ --accent: #2d6cbe;
13
+ --accent-light: #e8f0fb;
14
+ --border: #b0bec5;
15
+ --muted: #546e7a;
16
+ --bg: #ffffff;
17
+ --bg-alt: #f4f6f8;
18
+ }
19
+
20
+ body {
21
+ font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
22
+ font-size: 15px;
23
+ line-height: 1.75;
24
+ color: #263238;
25
+ margin: 0;
26
+ padding: 0;
27
+ background: var(--bg-alt);
28
+ }
29
+
30
+ .page-wrap {
31
+ max-width: 900px;
32
+ margin: 2rem auto;
33
+ background: var(--bg);
34
+ border: 1px solid var(--border);
35
+ border-radius: 4px;
36
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
37
+ }
38
+
39
+ /* ── Title banner ─────────────────────────────────────────── */
40
+ .doc-header {
41
+ background: var(--primary);
42
+ color: #fff;
43
+ padding: 2.5rem 3rem 2rem;
44
+ border-radius: 4px 4px 0 0;
45
+ }
46
+ .doc-header .doc-title {
47
+ font-size: 2rem;
48
+ font-weight: 700;
49
+ letter-spacing: 0.02em;
50
+ margin: 0;
51
+ color: #fff;
52
+ border: none;
53
+ }
54
+ .doc-header .doc-subtitle {
55
+ margin: 0.4rem 0 0;
56
+ font-size: 0.9rem;
57
+ opacity: 0.7;
58
+ letter-spacing: 0.03em;
59
+ }
60
+
61
+ .content {
62
+ padding: 2rem 3rem 3rem;
63
+ }
64
+
65
+ /* ── Headings ──────────────────────────────────────────────── */
66
+ h1, h2, h3, h4, h5, h6 {
67
+ font-weight: 700;
68
+ line-height: 1.25;
69
+ margin-top: 2em;
70
+ margin-bottom: 0.5em;
71
+ }
72
+ h1 {
73
+ font-size: 1.75em;
74
+ color: var(--primary);
75
+ border-bottom: 3px solid var(--accent);
76
+ padding-bottom: 0.35em;
77
+ }
78
+ h2 {
79
+ font-size: 1.35em;
80
+ color: var(--accent);
81
+ border-bottom: 1px solid var(--border);
82
+ padding-bottom: 0.25em;
83
+ }
84
+ h3 { font-size: 1.1em; color: var(--primary); }
85
+ h4 { font-size: 1em; color: var(--muted); }
86
+
87
+ a { color: var(--accent); text-decoration: none; }
88
+ a:hover { text-decoration: underline; }
89
+
90
+ p { margin: 0.75em 0; }
91
+
92
+ /* ── Code ──────────────────────────────────────────────────── */
93
+ code {
94
+ font-family: 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
95
+ font-size: 0.85em;
96
+ background: var(--bg-alt);
97
+ border: 1px solid var(--border);
98
+ padding: 0.15em 0.4em;
99
+ border-radius: 3px;
100
+ }
101
+ pre {
102
+ background: #1e2a38;
103
+ border-radius: 4px;
104
+ padding: 1.25rem;
105
+ overflow-x: auto;
106
+ line-height: 1.5;
107
+ }
108
+ pre code {
109
+ background: none;
110
+ border: none;
111
+ padding: 0;
112
+ font-size: 0.875em;
113
+ color: #cdd9e5;
114
+ }
115
+
116
+ /* ── Tables ────────────────────────────────────────────────── */
117
+ table {
118
+ border-collapse: collapse;
119
+ width: 100%;
120
+ margin: 1.25em 0;
121
+ outline: 1px solid var(--border);
122
+ border-radius: 4px;
123
+ overflow: hidden;
124
+ }
125
+ th {
126
+ background: var(--primary);
127
+ color: #fff;
128
+ font-weight: 600;
129
+ padding: 0.6em 1em;
130
+ text-align: left;
131
+ border: 1px solid #2a4f78;
132
+ }
133
+ td {
134
+ padding: 0.55em 1em;
135
+ border: 1px solid var(--border);
136
+ }
137
+ tr:nth-child(even) td { background: var(--accent-light); }
138
+ tr:hover td { background: #dde8f7; }
139
+
140
+ /* ── Blockquote ────────────────────────────────────────────── */
141
+ blockquote {
142
+ margin: 1em 0;
143
+ padding: 0.75em 1.25em;
144
+ border-left: 4px solid var(--accent);
145
+ background: var(--accent-light);
146
+ color: var(--muted);
147
+ border-radius: 0 4px 4px 0;
148
+ }
149
+ blockquote p { margin: 0; }
150
+
151
+ img { max-width: 100%; height: auto; }
152
+
153
+ ul, ol { padding-left: 1.5em; margin: 0.75em 0; }
154
+ li { margin: 0.3em 0; }
155
+
156
+ hr, .section-divider {
157
+ border: none;
158
+ border-top: 1px solid var(--border);
159
+ margin: 2.5rem 0;
160
+ }
161
+
162
+ /* ── Table of Contents ─────────────────────────────────────── */
163
+ .toc-block {
164
+ background: var(--accent-light);
165
+ border: 1px solid #b3c8ee;
166
+ border-left: 4px solid var(--accent);
167
+ border-radius: 0 4px 4px 0;
168
+ padding: 1rem 1.5rem;
169
+ margin: 1.5rem 0 2.5rem;
170
+ display: inline-block;
171
+ min-width: 280px;
172
+ }
173
+ .toc-block .toc-heading {
174
+ font-size: 0.7em;
175
+ font-weight: 700;
176
+ text-transform: uppercase;
177
+ letter-spacing: 0.1em;
178
+ color: var(--accent);
179
+ margin: 0 0 0.5rem;
180
+ }
181
+ .toc-block ul {
182
+ margin: 0;
183
+ padding-left: 1.25em;
184
+ list-style: none;
185
+ }
186
+ .toc-block ul ul { padding-left: 1em; }
187
+ .toc-block li { margin: 0.2em 0; }
188
+ .toc-block a { color: var(--primary); font-size: 0.9em; }
189
+ .toc-block a:hover { color: var(--accent); }
190
+
191
+ /* ── Mermaid ───────────────────────────────────────────────── */
192
+ .mermaid { text-align: center; margin: 1.5em 0; }
193
+ </style>
194
+ {% if has_mermaid %}
195
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
196
+ <script>mermaid.initialize({ startOnLoad: true, theme: 'neutral' });</script>
197
+ {% endif %}
198
+ </head>
199
+ <body>
200
+ <div class="page-wrap">
201
+
202
+ {% if title %}
203
+ <header class="doc-header">
204
+ <h1 class="doc-title">{{ title }}</h1>
205
+ <p class="doc-subtitle">Generated by mddoco</p>
206
+ </header>
207
+ {% endif %}
208
+
209
+ <div class="content">
210
+
211
+ {% if toc_html %}
212
+ <nav class="toc-block" aria-label="Table of contents">
213
+ <p class="toc-heading">Contents</p>
214
+ {{ toc_html | safe }}
215
+ </nav>
216
+ {% endif %}
217
+
218
+ <main>
219
+ {% for path, body in sections %}
220
+ {% if not loop.first %}
221
+ <hr class="section-divider">
222
+ {% endif %}
223
+ <section>{{ body | safe }}</section>
224
+ {% endfor %}
225
+ </main>
226
+
227
+ </div>
228
+ </div>
229
+ </body>
230
+ </html>
mddoco/toc.py ADDED
@@ -0,0 +1,15 @@
1
+ import re
2
+
3
+ _INNER_UL = re.compile(r"<ul>(.*)</ul>", re.DOTALL)
4
+
5
+
6
+ def combine_toc(fragments: list[str]) -> str:
7
+ """Merge per-file TOC HTML fragments into a single <ul> block."""
8
+ items: list[str] = []
9
+ for fragment in fragments:
10
+ m = _INNER_UL.search(fragment)
11
+ if m:
12
+ items.append(m.group(1).strip())
13
+ if not items:
14
+ return ""
15
+ return "<ul>\n" + "\n".join(items) + "\n</ul>"
mddoco/writer.py ADDED
@@ -0,0 +1,9 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def write_output(content: str, output_path: Path, filename: str) -> Path:
5
+ """Write content to output_path/filename, creating directories as needed."""
6
+ output_path.mkdir(parents=True, exist_ok=True)
7
+ dest = output_path / filename
8
+ dest.write_text(content, encoding="utf-8")
9
+ return dest
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: mddoco
3
+ Version: 0.1.0
4
+ Summary: Markdown to HTML/PDF document converter
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: click>=8.0
9
+ Requires-Dist: markdown>=3.5
10
+ Requires-Dist: jinja2>=3.1
11
+ Requires-Dist: playwright>=1.40
12
+ Requires-Dist: matplotlib>=3.7
13
+ Dynamic: license-file
14
+
15
+ # mddoco
16
+
17
+ A CLI tool that converts markdown files to a single HTML (or future PDF) document.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install mddoco
23
+ playwright install chromium
24
+ ```
25
+
26
+ Or for development:
27
+
28
+ ```bash
29
+ pip install -e .
30
+ playwright install chromium
31
+ ```
32
+
33
+ > Playwright (Chromium) is required for PDF output only. HTML output works without it.
34
+
35
+ ## Usage
36
+
37
+ ```
38
+ mddoco [OPTIONS] INPUT_PATH
39
+ ```
40
+
41
+ `INPUT_PATH` can be a **directory** (all `*.md` files are found recursively and sorted) or a **single `.md` file**.
42
+
43
+ ### Options
44
+
45
+ | Option | Short | Default | Description |
46
+ |---|---|---|---|
47
+ | `--output PATH` | `-o` | `.` | Directory to write the output file |
48
+ | `--format [html\|pdf]` | `-f` | `html` | Output format |
49
+ | `--title TEXT` | `-t` | _(none)_ | Document title shown at the top of the page |
50
+ | `--theme NAME` | | `default` | Theme to use for rendering |
51
+ | `--toc / --no-toc` | | off | Generate a table of contents |
52
+ | `--toc-depth N` | | `3` | Maximum heading depth included in the TOC (1–6) |
53
+
54
+ ### Examples
55
+
56
+ Convert all markdown files in a directory:
57
+
58
+ ```bash
59
+ mddoco ./docs
60
+ ```
61
+
62
+ Convert a single file:
63
+
64
+ ```bash
65
+ mddoco README.md
66
+ ```
67
+
68
+ With a title, TOC, and custom output directory:
69
+
70
+ ```bash
71
+ mddoco ./docs --title "My Project" --toc --output ./out
72
+ ```
73
+
74
+ ## Output
75
+
76
+ All matched markdown files are combined into a single HTML document in the output directory. The filename is derived from the input folder or file name.
77
+
78
+ ## Themes
79
+
80
+ Themes are self-contained Jinja2 HTML files with embedded CSS. The `default` theme includes:
81
+
82
+ - Clean typographic layout
83
+ - Syntax-highlighted code blocks
84
+ - Styled tables and blockquotes
85
+ - Optional document title
86
+ - Optional table of contents
87
+ - [Mermaid.js](https://mermaid.js.org) diagram support (loaded only when diagrams are present)
88
+
89
+ ### Graph diagrams
90
+
91
+ Use ` ```graph ` fenced blocks with a JSON payload. The only required field is `data`.
92
+
93
+ **Minimal example** — all defaults applied:
94
+
95
+ ````markdown
96
+ ```graph
97
+ {
98
+ "data": {
99
+ "x": ["Jan", "Feb", "Mar"],
100
+ "Sales": [100, 150, 120]
101
+ }
102
+ }
103
+ ```
104
+ ````
105
+
106
+ Series are inferred from the data keys (everything except `x`). Each series defaults to a line chart in blue.
107
+
108
+ **Full schema:**
109
+
110
+ | Field | Required | Default | Description |
111
+ |---|---|---|---|
112
+ | `data.x` | yes | — | Category labels |
113
+ | `data.<name>` | yes | — | Values for each series (one key per series) |
114
+ | `title` | no | _(none)_ | Chart title |
115
+ | `orientation` | no | `vertical` | `vertical` or `horizontal` |
116
+ | `show_legend` | no | `true` | Show the legend |
117
+ | `width_px` | no | `640` | Width in pixels |
118
+ | `height_px` | no | `480` | Height in pixels |
119
+ | `min` / `max` | no | — | Axis bounds |
120
+ | `series` | no | _(auto)_ | Override series definitions (see below) |
121
+
122
+ **Series fields** (all optional when auto-generated):
123
+
124
+ | Field | Default | Description |
125
+ |---|---|---|
126
+ | `label` | _(data key)_ | Must match a key in `data` |
127
+ | `type` | `line` | `bar`, `line` (solid), or `line2` (dotted) |
128
+ | `colour` | _(palette)_ | Colour string, or list of colours per bar |
129
+ | `marker` | `false` | Show point markers (line types only) |
130
+
131
+ **Full example:**
132
+
133
+ ````markdown
134
+ ```graph
135
+ {
136
+ "title": "Sales vs Target",
137
+ "orientation": "vertical",
138
+ "show_legend": true,
139
+ "data": {
140
+ "x": ["Jan", "Feb", "Mar", "Apr", "May"],
141
+ "Sales": [85, 92, 78, 96, 110],
142
+ "Target": [90, 90, 90, 90, 90]
143
+ },
144
+ "series": [
145
+ { "label": "Sales", "type": "bar", "colour": "#3498db" },
146
+ { "label": "Target", "type": "line", "colour": "#e74c3c", "marker": false }
147
+ ]
148
+ }
149
+ ```
150
+ ````
151
+
152
+ ### Mermaid diagrams
153
+
154
+ Use standard fenced code blocks in your markdown:
155
+
156
+ ````markdown
157
+ ```mermaid
158
+ graph TD
159
+ A[Start] --> B[End]
160
+ ```
161
+ ````
162
+
163
+ mddoco detects mermaid blocks automatically and loads the Mermaid.js CDN only when needed.
@@ -0,0 +1,18 @@
1
+ mddoco/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ mddoco/cli.py,sha256=dzlJd3EbaMlf2SZFhA_LTfs0Z7ZcPlnC0L89V7iUgfA,2790
3
+ mddoco/converter.py,sha256=_82oF32T-uc9AazSj1DIs_8d-SNR4476Jcan_3l8uTA,1231
4
+ mddoco/graph.py,sha256=x9VpO2-bajmzQxqJkmIgl2M8u8dC_neoJ4dKfdaWRo8,3814
5
+ mddoco/mermaid.py,sha256=bWq3nY19PhkSS2_3xR3Md34tsIk_rZ7mi6kKyDxHthg,545
6
+ mddoco/pdf.py,sha256=a-O1Xbd35xmCCF4_YJs5yzPdt-KgZgjlWUSWQq4a2XY,1115
7
+ mddoco/renderer.py,sha256=l3kt4aKQWeXOD-kAr388K7rqGUacaQk9HPmstMsjWx0,894
8
+ mddoco/scanner.py,sha256=EX3RTMQGZrAb6xOlVQvdjaA9-xErRntf7zFg5mvLyPk,732
9
+ mddoco/toc.py,sha256=4h3lSjPmWvdwWeRgX6-nCikenF0QVayZ8Aqd_0pbYUA,423
10
+ mddoco/writer.py,sha256=KnTaFm3BI-Skx-UDZmUJNV7JI9f1Pq8fwOOoAyDvKp0,330
11
+ mddoco/themes/default.html,sha256=ePIOAIm7A7zwCSeRM4qomLQRlk7ie4uLPojHQN0ywlQ,4641
12
+ mddoco/themes/professional.html,sha256=8cwh-3L3bcBIRmjZ57Iw8FwI3X8UUKcH4_0J0C110NI,7661
13
+ mddoco-0.1.0.dist-info/licenses/LICENSE,sha256=JTTt6kL0A6OVPRn5wc8igU30mo5Wi2wZ2wai5dcY3H0,1068
14
+ mddoco-0.1.0.dist-info/METADATA,sha256=4KAzsXwFmSDFJv4IMfjEzM_I4hWkd1HCOlE9K4ZUz54,4084
15
+ mddoco-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
16
+ mddoco-0.1.0.dist-info/entry_points.txt,sha256=PbJ-QZBElwOzXK1NpkO07SdArunbFgyPycv4_UxPAko,43
17
+ mddoco-0.1.0.dist-info/top_level.txt,sha256=Eg5Eig5yjjCYwuJ1D3NnrHnnMBJo83ryWd_tPVNtbOo,7
18
+ mddoco-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mddoco = mddoco.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Phil Massyn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ mddoco