code-down 2.0.1__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.
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: code-down
3
+ Version: 2.0.1
4
+ Summary: A CLI tool that converts Markdown files into beautifully themed PDFs with syntax-highlighted code blocks.
5
+ Author-email: bouajila <bouajilamedyessine@gmail.com>
6
+ Project-URL: Homepage, https://github.com/bouajilaProg/CodeDown
7
+ Project-URL: Repository, https://github.com/bouajilaProg/CodeDown
8
+ Project-URL: Documentation, https://github.com/bouajilaProg/CodeDown#readme
9
+ Project-URL: Bug Tracker, https://github.com/bouajilaProg/CodeDown/issues
10
+ Keywords: cli,markdown,pdf,converter,documentation
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: markdown>=3.4
18
+ Requires-Dist: pygments>=2.15
19
+ Requires-Dist: weasyprint>=60.0
20
+ Requires-Dist: typer[all]>=0.12.0
21
+ Requires-Dist: click<9.0,>=8.1.7
22
+ Requires-Dist: InquirerPy>=0.3
23
+ Requires-Dist: watchdog>=3.0
24
+ Requires-Dist: requests>=2.28
25
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8.0; extra == "dev"
28
+
29
+ # codeDown
30
+
31
+ [![PyPI version](https://img.shields.io/pypi/v/code-down?color=blue)](https://pypi.org/project/code-down/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/code-down)](https://pypi.org/project/code-down/)
33
+ [![License: MIT](https://img.shields.io/github/license/bouajilaProg/CodeDown)](https://github.com/bouajilaProg/CodeDown/blob/main/LICENSE)
34
+ [![Release](https://img.shields.io/github/actions/workflow/status/bouajilaProg/CodeDown/release.yml?label=release)](https://github.com/bouajilaProg/CodeDown/actions/workflows/release.yml)
35
+ [![GitHub Release](https://img.shields.io/github/v/release/bouajilaProg/CodeDown?label=latest)](https://github.com/bouajilaProg/CodeDown/releases/latest)
36
+
37
+ **codeDown** is a simple yet powerful **CLI tool** that converts Markdown (`.md`) files into **beautiful themed PDFs** — complete with **syntax-highlighted code blocks**.
38
+
39
+ Built for developers who love clean documentation, readable code snippets, and automated workflows.
40
+
41
+ ---
42
+
43
+ ## Features
44
+
45
+ * **Syntax Highlighting** for code blocks
46
+ * **Selectable Themes** – interactive picker or CLI flag (`light`, `dark`)
47
+ * **Watch Mode** – auto-regenerate PDF on file save
48
+ * **Self-Update** – update from the CLI (`code-down update`)
49
+ * **Configurable** – set a default theme via `code-down config set-theme`
50
+ * **Fast & Lightweight** – converts Markdown to PDF in seconds
51
+
52
+ ---
53
+
54
+ ## Installation
55
+
56
+ ### Via pip
57
+
58
+ ```bash
59
+ pip install code-down
60
+ ```
61
+
62
+ ### Via binary (Linux)
63
+
64
+ 1. **Download the latest release**
65
+ Visit the [Releases Page](https://github.com/bouajilaProg/CodeDown/releases) and download the latest Linux binary.
66
+
67
+ 2. **Make it executable and move to PATH**
68
+
69
+ ```bash
70
+ chmod +x code-down
71
+ sudo mv code-down /usr/local/bin/
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Usage
77
+
78
+ Convert a Markdown file into a themed PDF:
79
+
80
+ ```bash
81
+ code-down input.md output.pdf -s dark
82
+ ```
83
+
84
+ ### Watch mode
85
+
86
+ Automatically rebuild the PDF when the Markdown file changes:
87
+
88
+ ```bash
89
+ code-down -w input.md
90
+ ```
91
+
92
+ ### Options
93
+
94
+ | Flag | Description | Default |
95
+ | ----------------- | --------------------------------------- | ----------------------------------- |
96
+ | `-o, --output` | Output PDF file path | Same as input with `.pdf` extension |
97
+ | `-s, --style` | Theme style (e.g. `light`, `dark`) | Config default or `dark` |
98
+ | `-w, --watch` | Watch file and rebuild PDF on changes | |
99
+ | `-v, --version` | Print version and exit | |
100
+
101
+ ### Commands
102
+
103
+ | Command | Description |
104
+ | -------------------- | --------------------------------------------- |
105
+ | `code-down themes` | Pick a theme interactively (sets as default) |
106
+ | `code-down config show` | Show current configuration |
107
+ | `code-down config set-theme` | Set the default theme (interactive or by name) |
108
+ | `code-down update` | Update codeDown to the latest version |
109
+
110
+ ### Examples
111
+
112
+ Quick test file:
113
+
114
+ ```bash
115
+ code-down examples/example.md
116
+ ```
117
+
118
+ Convert `README.md` to `README.pdf` using the default theme:
119
+
120
+ ```bash
121
+ code-down README.md
122
+ ```
123
+
124
+ Convert with a dark theme and custom output name:
125
+
126
+ ```bash
127
+ code-down README.md -o README_dark.pdf -s dark
128
+ ```
129
+
130
+ Watch a file and rebuild on every save:
131
+
132
+ ```bash
133
+ code-down -w notes.md -s light
134
+ ```
135
+
136
+ Pick a theme interactively:
137
+
138
+ ```bash
139
+ code-down themes
140
+ ```
141
+
142
+ ---
143
+
144
+ ## Notes
145
+
146
+ * Ensure your Markdown files are UTF-8 encoded for best results.
147
+ * Supports syntax highlighting for most major programming languages.
148
+ * Works completely offline — no internet connection required (except for `update`).
@@ -0,0 +1,19 @@
1
+ codedown/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ codedown/__main__.py,sha256=UeAguGS48NLruFMyP1aeXPLorjXtSieGfWPUgOa1B68,981
3
+ codedown/cli.py,sha256=T-JSP-yvMRD05yrDCPyxaw2CszSGBZYZj4oWFtJwRd4,7394
4
+ codedown/config.py,sha256=THil9juWAXTNdjYRMnYZOjcg4Gqc0VzuGdnTdhfV8Is,1221
5
+ codedown/converter.py,sha256=D0IN39ka2HPCngS-ewRQx7U2enlw8kLgrNuAlLJjm_E,1271
6
+ codedown/paths.py,sha256=EriJ7feYBy0k-BVxV2uYKFwfmGaP_v45BWi3XULYkDg,146
7
+ codedown/themes.py,sha256=3bLs4BWHzppDNQAePHkpLl9OoPM7nFeZGw6rDHb7IfE,1685
8
+ codedown/updater.py,sha256=37qLZNOE8hpVr-eNm82dyV4CqAQK9dId6mH3KO7Ivno,3871
9
+ codedown/watcher.py,sha256=llSCn_E_9c9Xs2z2sXfkkn0VhUMNT0_I2Q620fDwAas,1960
10
+ codedown/assets/themes/base.css,sha256=cf_PaOSqSVmVgpjwN94x3T4BY8VFfqUAmuszVbkaCa8,2076
11
+ codedown/assets/themes/dark.css,sha256=W9NSkSe7EXJrHDrNvq8U7uGWgp2n4jGKeSJkgLXzwMw,615
12
+ codedown/assets/themes/dark.toml,sha256=6oAD0aaNsVLloav2OPkcc7QuGmB-7o_P1WmYPUndeik,77
13
+ codedown/assets/themes/light.css,sha256=BGtxeo-7kArASR5rjewX3E7EPOSfmwCqJRgzIByef9w,646
14
+ codedown/assets/themes/light.toml,sha256=mlKb4gDkSTUFSaKIKLR6trGRdvTy_isjFJM0s4GO67c,79
15
+ code_down-2.0.1.dist-info/METADATA,sha256=WDq9j0PJMJUnmaLELzHINl1a5gFcI5sl55lWGyKt6PY,4877
16
+ code_down-2.0.1.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
17
+ code_down-2.0.1.dist-info/entry_points.txt,sha256=VV12-3wgXOTMrNrhsBMM4BdCZl7X8k9CtJ90b_l4Qvg,53
18
+ code_down-2.0.1.dist-info/top_level.txt,sha256=rE1C2xNDH3XWkaQrHsvrpIOE_CwBl0aPe9xpD-f2Tsg,9
19
+ code_down-2.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ code-down = codedown.__main__:main
@@ -0,0 +1 @@
1
+ codedown
codedown/__init__.py ADDED
File without changes
codedown/__main__.py ADDED
@@ -0,0 +1,41 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ from codedown.cli import app
5
+
6
+
7
+ _SUBCOMMANDS = {"convert", "config", "themes", "update"}
8
+ _CONVERT_LEADING_OPTIONS = {"-w", "--watch", "-s", "--style", "-o", "--output"}
9
+
10
+
11
+ def _rewrite_argv_for_implicit_convert(argv: list[str]) -> list[str]:
12
+ if len(argv) < 2:
13
+ return argv
14
+
15
+ first = argv[1]
16
+
17
+ # Let click/typer handle help/version and explicit subcommands.
18
+ if first in {"-h", "--help", "-v", "--version"}:
19
+ return argv
20
+ if first in _SUBCOMMANDS:
21
+ return argv
22
+
23
+ # Support: code-down -w file.md (and -s/-o before the file)
24
+ if first in _CONVERT_LEADING_OPTIONS:
25
+ return [argv[0], "convert", *argv[1:]]
26
+
27
+ # Support: code-down file.md
28
+ p = Path(first)
29
+ if p.exists() or first.lower().endswith(".md"):
30
+ return [argv[0], "convert", *argv[1:]]
31
+
32
+ return argv
33
+
34
+
35
+ def main():
36
+ sys.argv = _rewrite_argv_for_implicit_convert(sys.argv)
37
+ app()
38
+
39
+
40
+ if __name__ == "__main__":
41
+ main()
@@ -0,0 +1,132 @@
1
+ html,
2
+ body {
3
+ margin: 0;
4
+ padding: 0;
5
+ background-color: var(--theme-bg);
6
+ color: var(--theme-text);
7
+ font-family: "Fira Code", "JetBrains Mono", Consolas, monospace;
8
+ line-height: 1.6;
9
+ font-size: 16px;
10
+ padding-left: 20px;
11
+ padding-right: 20px;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ padding-top: 10px;
17
+ padding-bottom: 10px;
18
+ }
19
+
20
+ h1,
21
+ h2,
22
+ h3,
23
+ h4,
24
+ h5,
25
+ h6 {
26
+ color: var(--theme-header);
27
+ border-bottom: 1px solid var(--theme-border);
28
+ padding-bottom: 0.2em;
29
+ }
30
+
31
+ a {
32
+ color: var(--theme-link);
33
+ text-decoration: underline;
34
+ }
35
+
36
+ a:hover {
37
+ text-decoration: underline;
38
+ }
39
+
40
+ pre,
41
+ code {
42
+ font-family: "Fira Code", "JetBrains Mono", Consolas, monospace;
43
+ }
44
+
45
+ pre {
46
+ background-color: var(--theme-code-bg);
47
+ padding: 12px 16px;
48
+ border-radius: 8px;
49
+ overflow-x: auto;
50
+ color: var(--theme-text);
51
+ box-shadow: 0 0 10px var(--theme-code-shadow);
52
+ white-space: pre-wrap;
53
+ word-wrap: break-word;
54
+ }
55
+
56
+ blockquote {
57
+ border-left: 4px solid var(--theme-blockquote-border);
58
+ margin: 1em 0;
59
+ padding-left: 1em;
60
+ color: var(--theme-blockquote-text);
61
+ background-color: var(--theme-blockquote-bg);
62
+ }
63
+
64
+ hr {
65
+ border: none;
66
+ border-top: 1px solid var(--theme-hr-border);
67
+ margin: 2em 0;
68
+ }
69
+
70
+ img {
71
+ max-width: 100%;
72
+ border-radius: 6px;
73
+ }
74
+
75
+ table {
76
+ width: 100%;
77
+ border-collapse: collapse;
78
+ margin: 1em 0;
79
+ }
80
+
81
+ th,
82
+ td {
83
+ border: 1px solid var(--theme-hr-border);
84
+ padding: 8px 12px;
85
+ text-align: left;
86
+ }
87
+
88
+ th {
89
+ background-color: var(--theme-table-header-bg);
90
+ color: var(--theme-table-header-text);
91
+ }
92
+
93
+ @media print {
94
+ @page {
95
+ size: auto;
96
+ margin: 20px;
97
+ background-color: var(--theme-bg);
98
+ }
99
+
100
+ html,
101
+ body {
102
+ background-color: var(--theme-bg);
103
+ -webkit-print-color-adjust: exact;
104
+ print-color-adjust: exact;
105
+ padding-left: 20px;
106
+ padding-right: 20px;
107
+ padding-top: 10px;
108
+ padding-bottom: 10px;
109
+ }
110
+
111
+ pre,
112
+ img,
113
+ table,
114
+ blockquote {
115
+ page-break-inside: avoid;
116
+ break-inside: avoid;
117
+ }
118
+
119
+ h1,
120
+ h2,
121
+ h3,
122
+ h4,
123
+ h5,
124
+ h6 {
125
+ page-break-after: avoid;
126
+ break-after: avoid;
127
+ }
128
+
129
+ tr {
130
+ page-break-inside: avoid;
131
+ }
132
+ }
@@ -0,0 +1,27 @@
1
+ /* Dark Theme Variables (Tokyonight-inspired) */
2
+ :root {
3
+ /* Core Colors */
4
+ --theme-bg: #1a1b26;
5
+ --theme-text: #c0caf5;
6
+
7
+ /* Accent Colors */
8
+ --theme-header: #7aa2f7;
9
+ --theme-link: #7dcfff;
10
+
11
+ /* Structural Colors */
12
+ --theme-border: #2f3549;
13
+ --theme-hr-border: #3b4261;
14
+
15
+ /* Code Block Colors */
16
+ --theme-code-bg: #24283b;
17
+ --theme-code-shadow: rgba(0, 0, 0, 0.3);
18
+
19
+ /* Blockquote Colors */
20
+ --theme-blockquote-bg: #1f2335;
21
+ --theme-blockquote-border: #565f89;
22
+ --theme-blockquote-text: #a9b1d6;
23
+
24
+ /* Table Colors */
25
+ --theme-table-header-bg: #2f3549;
26
+ --theme-table-header-text: #bb9af7;
27
+ }
@@ -0,0 +1,4 @@
1
+ name = "dark"
2
+ css_file = "dark.css"
3
+ code_theme = "monokai"
4
+ version = "1.0.0"
@@ -0,0 +1,28 @@
1
+ /* Light Theme Variables (High-Contrast) */
2
+ :root {
3
+ /* Core Colors */
4
+ --theme-bg: #f9f9f9;
5
+ --theme-text: #333333;
6
+
7
+ /* Accent Colors */
8
+ --theme-header: #007acc;
9
+ --theme-link: #005f99;
10
+
11
+ /* Structural Colors */
12
+ --theme-border: #cccccc;
13
+ --theme-hr-border: #bbbbbb;
14
+
15
+ /* Code Block Colors */
16
+ --theme-code-bg: #eeeeee;
17
+ --theme-code-shadow: rgba(0, 0, 0, 0.1);
18
+
19
+ /* Blockquote Colors */
20
+ --theme-blockquote-bg: #f0f8ff;
21
+ --theme-blockquote-border: #7db0e5;
22
+ --theme-blockquote-text: #444444;
23
+ /* Darker text for readability */
24
+
25
+ /* Table Colors */
26
+ --theme-table-header-bg: #e0e0e0;
27
+ --theme-table-header-text: #444444;
28
+ }
@@ -0,0 +1,4 @@
1
+ name = "light"
2
+ css_file = "light.css"
3
+ code_theme = "default"
4
+ version = "1.0.0"
codedown/cli.py ADDED
@@ -0,0 +1,266 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+
4
+ import typer
5
+
6
+
7
+ def _get_package_version() -> str:
8
+ try:
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ return version("code-down")
12
+ except Exception:
13
+ return "unknown"
14
+
15
+
16
+ def _version_callback(value: bool):
17
+ if not value:
18
+ return
19
+ typer.echo(_get_package_version())
20
+ raise typer.Exit(0)
21
+
22
+
23
+ app = typer.Typer(
24
+ name="code-down",
25
+ help="Convert Markdown files into beautifully themed PDFs with syntax-highlighted code blocks.",
26
+ add_completion=False,
27
+ no_args_is_help=True,
28
+ context_settings={"help_option_names": ["-h", "--help"]},
29
+ )
30
+
31
+ config_app = typer.Typer(
32
+ help="Manage codeDown configuration.",
33
+ no_args_is_help=True,
34
+ context_settings={"help_option_names": ["-h", "--help"]},
35
+ )
36
+ app.add_typer(config_app, name="config")
37
+
38
+
39
+ @app.callback()
40
+ def _global_options(
41
+ version: bool = typer.Option(
42
+ False,
43
+ "-v",
44
+ "--version",
45
+ help="Show version and exit",
46
+ callback=_version_callback,
47
+ is_eager=True,
48
+ ),
49
+ ):
50
+ return
51
+
52
+
53
+ def _resolve_output_file(
54
+ input_file: Path,
55
+ output_arg: Optional[Path],
56
+ output_opt: Optional[Path],
57
+ ) -> Path:
58
+ if output_arg is not None and output_opt is not None:
59
+ typer.echo(
60
+ "Error: provide output either as 2nd argument or via -o/--output", err=True
61
+ )
62
+ raise typer.Exit(code=2)
63
+
64
+ output_raw = output_opt or output_arg
65
+ if output_raw is None:
66
+ return input_file.with_suffix(".pdf")
67
+
68
+ # Treat as directory if it is a dir, or has no suffix (e.g. `temp`).
69
+ if output_raw.exists() and output_raw.is_dir():
70
+ output_dir = output_raw
71
+ output_dir.mkdir(parents=True, exist_ok=True)
72
+ return output_dir / f"{input_file.stem}.pdf"
73
+
74
+ if output_raw.suffix == "":
75
+ output_dir = output_raw
76
+ output_dir.mkdir(parents=True, exist_ok=True)
77
+ return output_dir / f"{input_file.stem}.pdf"
78
+
79
+ if output_raw.suffix.lower() != ".pdf":
80
+ output_raw = output_raw.with_suffix(".pdf")
81
+
82
+ output_raw.parent.mkdir(parents=True, exist_ok=True)
83
+ return output_raw
84
+
85
+
86
+ def _theme_choice_values(current: str) -> list[str]:
87
+ from codedown.themes import get_all_themes
88
+
89
+ names = sorted({t.name for t in get_all_themes()}, key=str.lower)
90
+ if current in names:
91
+ names.remove(current)
92
+ return [current, *names]
93
+ return names
94
+
95
+
96
+ @app.command("convert")
97
+ def convert_command(
98
+ input_file: Path = typer.Argument(..., help="Input Markdown file to convert"),
99
+ output_location: Optional[Path] = typer.Argument(
100
+ None, help="Output directory or PDF path (optional)"
101
+ ),
102
+ output: Optional[Path] = typer.Option(
103
+ None, "-o", "--output", help="Output PDF file path"
104
+ ),
105
+ style: Optional[str] = typer.Option(
106
+ None, "-s", "--style", help="Theme style (e.g. light, dark)"
107
+ ),
108
+ watch: bool = typer.Option(
109
+ False, "-w", "--watch", help="Watch the file and rebuild PDF on changes"
110
+ ),
111
+ ):
112
+ """Convert a Markdown file into a themed PDF."""
113
+ if watch:
114
+ _do_watch(input_file, output_location, output, style)
115
+ else:
116
+ _do_convert(input_file, output_location, output, style)
117
+
118
+
119
+ def _do_convert(
120
+ input_file: Path,
121
+ output_location: Optional[Path] = None,
122
+ output_opt: Optional[Path] = None,
123
+ style: Optional[str] = None,
124
+ ):
125
+ """Core conversion logic shared by convert and watch."""
126
+ from codedown.config import load_config
127
+ from codedown.converter import ConverterEngine
128
+
129
+ if not input_file.exists():
130
+ typer.echo(f"Error: Input file '{input_file}' does not exist", err=True)
131
+ raise typer.Exit(code=1)
132
+
133
+ output_file = _resolve_output_file(input_file, output_location, output_opt)
134
+ config = load_config()
135
+ theme_name = style or config.get("default_theme", "dark")
136
+
137
+ markdown_text = input_file.read_text(encoding="utf-8")
138
+ converter = ConverterEngine(markdown_text)
139
+ converter.convert_to_pdf(str(output_file), style=theme_name)
140
+
141
+ typer.echo(f"PDF successfully created: {output_file}")
142
+
143
+
144
+ def _do_watch(
145
+ input_file: Path,
146
+ output_location: Optional[Path] = None,
147
+ output_opt: Optional[Path] = None,
148
+ style: Optional[str] = None,
149
+ ):
150
+ """Watch the input file and rebuild PDF on changes."""
151
+ from codedown.config import load_config
152
+ from codedown.watcher import watch_and_convert
153
+
154
+ if not input_file.exists():
155
+ typer.echo(f"Error: Input file '{input_file}' does not exist", err=True)
156
+ raise typer.Exit(code=1)
157
+
158
+ output_file = _resolve_output_file(input_file, output_location, output_opt)
159
+ config = load_config()
160
+ theme_name = style or config.get("default_theme", "dark")
161
+
162
+ watch_and_convert(input_file, output_file, theme_name)
163
+
164
+
165
+ # --- config subcommands ---
166
+
167
+
168
+ @config_app.command("show")
169
+ def config_show():
170
+ """Show current configuration."""
171
+ from codedown.config import CONFIG_FILE, load_config
172
+
173
+ config = load_config()
174
+ if not config:
175
+ typer.echo("No configuration set. Using defaults.")
176
+ typer.echo(f" Config file: {CONFIG_FILE}")
177
+ typer.echo(f" default_theme = dark")
178
+ return
179
+
180
+ typer.echo(f"Config file: {CONFIG_FILE}")
181
+ for key, value in config.items():
182
+ typer.echo(f" {key} = {value}")
183
+
184
+
185
+ @config_app.command("set-theme")
186
+ def config_set_theme(
187
+ theme_name: Optional[str] = typer.Argument(
188
+ None, help="Theme name to set as default"
189
+ ),
190
+ ):
191
+ """Set the default theme. Run without arguments for interactive picker."""
192
+ from codedown.config import set_default_theme
193
+ from codedown.themes import get_theme_by_name
194
+
195
+ if theme_name is None:
196
+ _pick_and_set_theme()
197
+ return
198
+
199
+ # Validate the theme exists
200
+ get_theme_by_name(theme_name)
201
+ set_default_theme(theme_name)
202
+ typer.echo(f"Default theme set to '{theme_name}'")
203
+
204
+
205
+ def _pick_and_set_theme():
206
+ """Interactive theme picker using InquirerPy."""
207
+ from InquirerPy import inquirer
208
+
209
+ from codedown.config import get_default_theme, set_default_theme
210
+
211
+ current = get_default_theme()
212
+ theme_names = _theme_choice_values(current)
213
+
214
+ choices = [{"name": name, "value": name} for name in theme_names]
215
+
216
+ selected = inquirer.select(
217
+ message="Select default theme:",
218
+ choices=choices,
219
+ default=current,
220
+ ).execute()
221
+
222
+ if selected is None:
223
+ typer.echo("Cancelled.")
224
+ raise typer.Exit(0)
225
+
226
+ set_default_theme(selected)
227
+ typer.echo(f"Default theme set to '{selected}'")
228
+
229
+
230
+ # --- themes command ---
231
+
232
+
233
+ @app.command("themes")
234
+ def themes_command():
235
+ """Browse and select a theme interactively."""
236
+ from InquirerPy import inquirer
237
+
238
+ from codedown.config import get_default_theme, set_default_theme
239
+
240
+ current = get_default_theme()
241
+ theme_names = _theme_choice_values(current)
242
+
243
+ choices = [{"name": name, "value": name} for name in theme_names]
244
+
245
+ selected = inquirer.select(
246
+ message="Pick a theme:",
247
+ choices=choices,
248
+ default=current,
249
+ ).execute()
250
+
251
+ if selected is None:
252
+ raise typer.Exit(0)
253
+
254
+ set_default_theme(selected)
255
+ typer.echo(f"Default theme set to '{selected}'")
256
+
257
+
258
+ # --- update command ---
259
+
260
+
261
+ @app.command("update")
262
+ def update_command():
263
+ """Update codeDown to the latest version."""
264
+ from codedown.updater import run_update
265
+
266
+ run_update()
codedown/config.py ADDED
@@ -0,0 +1,48 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ if sys.version_info >= (3, 11):
5
+ import tomllib
6
+ else:
7
+ try:
8
+ import tomllib
9
+ except ImportError:
10
+ import tomli as tomllib
11
+
12
+ CONFIG_DIR = Path.home() / ".config" / "codedown"
13
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
14
+
15
+
16
+ def load_config() -> dict:
17
+ if not CONFIG_FILE.exists():
18
+ return {}
19
+ with open(CONFIG_FILE, "rb") as f:
20
+ return tomllib.load(f)
21
+
22
+
23
+ def save_config(config: dict) -> None:
24
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
25
+
26
+ lines = []
27
+ for key, value in config.items():
28
+ if isinstance(value, str):
29
+ lines.append(f'{key} = "{value}"')
30
+ elif isinstance(value, bool):
31
+ lines.append(f"{key} = {'true' if value else 'false'}")
32
+ elif isinstance(value, int):
33
+ lines.append(f"{key} = {value}")
34
+ else:
35
+ lines.append(f'{key} = "{value}"')
36
+
37
+ CONFIG_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
38
+
39
+
40
+ def get_default_theme() -> str:
41
+ config = load_config()
42
+ return config.get("default_theme", "dark")
43
+
44
+
45
+ def set_default_theme(theme_name: str) -> None:
46
+ config = load_config()
47
+ config["default_theme"] = theme_name
48
+ save_config(config)
codedown/converter.py ADDED
@@ -0,0 +1,36 @@
1
+ from codedown.themes import Theme, get_theme_by_name
2
+
3
+
4
+ class ConverterEngine:
5
+ def __init__(self, markdown_text: str):
6
+ self.markdown_text = markdown_text
7
+
8
+ def convert_to_html(self) -> str:
9
+ import markdown
10
+ from markdown.extensions.codehilite import CodeHiliteExtension
11
+ from markdown.extensions.tables import TableExtension
12
+
13
+ html = markdown.markdown(
14
+ self.markdown_text,
15
+ extensions=[
16
+ "fenced_code",
17
+ CodeHiliteExtension(linenums=False, css_class="highlight"),
18
+ TableExtension(),
19
+ ],
20
+ )
21
+ return html
22
+
23
+ def apply_theme(self, html_content: str, theme: Theme) -> str:
24
+ return f"""<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
25
+ <title>Markdown Preview</title><style>{theme.get_css()}
26
+ </style></head>
27
+ <body>{html_content}</body></html>"""
28
+
29
+ def convert_to_pdf(self, output_pdf_path: str, style: str = "dark"):
30
+ from weasyprint import HTML
31
+
32
+ html_content = self.convert_to_html()
33
+ theme = get_theme_by_name(style)
34
+ full_html = self.apply_theme(html_content, theme)
35
+
36
+ HTML(string=full_html).write_pdf(output_pdf_path)
codedown/paths.py ADDED
@@ -0,0 +1,5 @@
1
+ from pathlib import Path
2
+
3
+ BASE_DIR = Path(__file__).resolve().parent
4
+ ASSETS_DIR = BASE_DIR / "assets"
5
+ THEMES_DIR = BASE_DIR / "assets" / "themes"
codedown/themes.py ADDED
@@ -0,0 +1,67 @@
1
+ import sys
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ from pygments.formatters import HtmlFormatter
6
+
7
+ from codedown.paths import THEMES_DIR
8
+
9
+ if sys.version_info >= (3, 11):
10
+ import tomllib
11
+ else:
12
+ try:
13
+ import tomllib
14
+ except ImportError:
15
+ import tomli as tomllib
16
+
17
+
18
+ @dataclass
19
+ class Theme:
20
+ name: str
21
+ css_file: str
22
+ code_theme: str
23
+ version: str
24
+
25
+ def get_css(self) -> str:
26
+ css = ""
27
+
28
+ theme_path = THEMES_DIR / self.css_file
29
+ if theme_path.exists():
30
+ css += "\n" + theme_path.read_text(encoding="utf-8")
31
+
32
+ css = HtmlFormatter(style=self.code_theme).get_style_defs(".highlight") + css
33
+
34
+ base_theme_path = THEMES_DIR / "base.css"
35
+ if base_theme_path.exists():
36
+ css += "\n" + base_theme_path.read_text(encoding="utf-8")
37
+
38
+ return css
39
+
40
+ @classmethod
41
+ def from_toml(cls, path: Path) -> "Theme":
42
+ with open(path, "rb") as f:
43
+ data = tomllib.load(f)
44
+ return cls(
45
+ name=data["name"],
46
+ css_file=data["css_file"],
47
+ code_theme=data["code_theme"],
48
+ version=data.get("version", "1.0.0"),
49
+ )
50
+
51
+
52
+ def get_all_themes() -> list[Theme]:
53
+ themes = []
54
+ for toml_file in sorted(THEMES_DIR.glob("*.toml")):
55
+ themes.append(Theme.from_toml(toml_file))
56
+ return themes
57
+
58
+
59
+ def get_theme_by_name(name: str) -> Theme:
60
+ name = name.strip().lower()
61
+ for theme in get_all_themes():
62
+ if theme.name.lower() == name:
63
+ return theme
64
+
65
+ available = ", ".join(t.name for t in get_all_themes())
66
+ print(f"Error: Unknown theme '{name}'. Available: {available}")
67
+ sys.exit(1)
codedown/updater.py ADDED
@@ -0,0 +1,135 @@
1
+ import os
2
+ import platform
3
+ import shutil
4
+ import stat
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+
9
+ import requests
10
+ import typer
11
+
12
+ GITHUB_REPO = "bouajilaProg/CodeDown"
13
+ GITHUB_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
14
+ PYPI_PACKAGE_NAME = "code-down"
15
+
16
+
17
+ def get_current_version() -> str:
18
+ from importlib.metadata import version
19
+
20
+ try:
21
+ return version(PYPI_PACKAGE_NAME)
22
+ except Exception:
23
+ return "unknown"
24
+
25
+
26
+ def is_pyinstaller_bundle() -> bool:
27
+ return getattr(sys, "_MEIPASS", None) is not None
28
+
29
+
30
+ def get_latest_github_release() -> dict:
31
+ response = requests.get(GITHUB_API_URL, timeout=15)
32
+ response.raise_for_status()
33
+ return response.json()
34
+
35
+
36
+ def update_via_pip():
37
+ """Update using pip install --upgrade."""
38
+ current = get_current_version()
39
+ typer.echo(f"Current version: {current}")
40
+ typer.echo("Checking PyPI for updates...")
41
+
42
+ result = subprocess.run(
43
+ [sys.executable, "-m", "pip", "install", "--upgrade", PYPI_PACKAGE_NAME],
44
+ capture_output=True,
45
+ text=True,
46
+ )
47
+
48
+ if result.returncode != 0:
49
+ typer.echo(f"Error updating via pip:\n{result.stderr}", err=True)
50
+ raise typer.Exit(code=1)
51
+
52
+ new_version = get_current_version()
53
+ if new_version == current:
54
+ typer.echo("Already up to date.")
55
+ else:
56
+ typer.echo(f"Updated: {current} → {new_version}")
57
+
58
+
59
+ def update_via_binary():
60
+ """Update the standalone binary from GitHub Releases."""
61
+ current = get_current_version()
62
+ typer.echo(f"Current version: {current}")
63
+ typer.echo("Checking GitHub Releases for updates...")
64
+
65
+ try:
66
+ release = get_latest_github_release()
67
+ except Exception as e:
68
+ typer.echo(f"Error fetching release info: {e}", err=True)
69
+ raise typer.Exit(code=1)
70
+
71
+ tag = release.get("tag_name", "unknown")
72
+ typer.echo(f"Latest release: {tag}")
73
+
74
+ # Find the Linux binary asset
75
+ system = platform.system().lower()
76
+ asset = None
77
+ for a in release.get("assets", []):
78
+ name = a["name"].lower()
79
+ if system in name or "code-down" in name:
80
+ asset = a
81
+ break
82
+
83
+ if asset is None:
84
+ typer.echo(
85
+ f"No binary found for {system} in release {tag}. "
86
+ "Try updating via pip: pip install --upgrade code-down",
87
+ err=True,
88
+ )
89
+ raise typer.Exit(code=1)
90
+
91
+ typer.echo(f"Downloading {asset['name']}...")
92
+
93
+ response = requests.get(asset["browser_download_url"], stream=True, timeout=60)
94
+ response.raise_for_status()
95
+
96
+ current_exe = os.path.realpath(sys.executable)
97
+ if is_pyinstaller_bundle():
98
+ current_exe = os.path.realpath(sys.argv[0])
99
+
100
+ # Write to a temp file, then replace the current binary
101
+ fd, tmp_path = tempfile.mkstemp(prefix="code-down-update-")
102
+ try:
103
+ with os.fdopen(fd, "wb") as tmp:
104
+ for chunk in response.iter_content(chunk_size=8192):
105
+ tmp.write(chunk)
106
+
107
+ # Make executable
108
+ os.chmod(tmp_path, os.stat(tmp_path).st_mode | stat.S_IEXEC)
109
+
110
+ # Replace the current binary
111
+ shutil.move(tmp_path, current_exe)
112
+ typer.echo(f"Updated binary at {current_exe}")
113
+ except PermissionError:
114
+ typer.echo(
115
+ f"Permission denied. Try: sudo code-down update",
116
+ err=True,
117
+ )
118
+ # Clean up temp file
119
+ if os.path.exists(tmp_path):
120
+ os.unlink(tmp_path)
121
+ raise typer.Exit(code=1)
122
+ except Exception:
123
+ if os.path.exists(tmp_path):
124
+ os.unlink(tmp_path)
125
+ raise
126
+
127
+
128
+ def run_update():
129
+ """Auto-detect installation method and update accordingly."""
130
+ if is_pyinstaller_bundle():
131
+ typer.echo("Detected: standalone binary (PyInstaller)")
132
+ update_via_binary()
133
+ else:
134
+ typer.echo("Detected: pip-installed package")
135
+ update_via_pip()
codedown/watcher.py ADDED
@@ -0,0 +1,69 @@
1
+ import time
2
+ from pathlib import Path
3
+ from typing import Callable
4
+
5
+ import typer
6
+ from watchdog.events import FileModifiedEvent, FileSystemEventHandler
7
+ from watchdog.observers import Observer
8
+
9
+
10
+ class _MarkdownHandler(FileSystemEventHandler):
11
+ def __init__(self, target_file: Path, on_change: Callable[[], None]):
12
+ super().__init__()
13
+ self.target_file = target_file.resolve()
14
+ self.on_change = on_change
15
+ self._last_trigger = 0.0
16
+
17
+ def on_modified(self, event):
18
+ if event.is_directory:
19
+ return
20
+
21
+ if Path(event.src_path).resolve() != self.target_file:
22
+ return
23
+
24
+ # Debounce: ignore events within 1 second of each other
25
+ now = time.time()
26
+ if now - self._last_trigger < 1.0:
27
+ return
28
+ self._last_trigger = now
29
+
30
+ self.on_change()
31
+
32
+
33
+ def watch_and_convert(
34
+ input_file: Path,
35
+ output_file: Path,
36
+ style: str,
37
+ ):
38
+ """Watch a Markdown file and regenerate the PDF on every change."""
39
+ from codedown.converter import ConverterEngine
40
+
41
+ input_file = input_file.resolve()
42
+ watch_dir = input_file.parent
43
+
44
+ def rebuild():
45
+ try:
46
+ markdown_text = input_file.read_text(encoding="utf-8")
47
+ converter = ConverterEngine(markdown_text)
48
+ converter.convert_to_pdf(str(output_file), style=style)
49
+ typer.echo(f"[watch] Rebuilt: {output_file}")
50
+ except Exception as e:
51
+ typer.echo(f"[watch] Error: {e}", err=True)
52
+
53
+ # Initial build
54
+ rebuild()
55
+ typer.echo(f"[watch] Watching {input_file} for changes... (Ctrl+C to stop)")
56
+
57
+ handler = _MarkdownHandler(input_file, rebuild)
58
+ observer = Observer()
59
+ observer.schedule(handler, str(watch_dir), recursive=False)
60
+ observer.start()
61
+
62
+ try:
63
+ while True:
64
+ time.sleep(0.5)
65
+ except KeyboardInterrupt:
66
+ typer.echo("\n[watch] Stopped.")
67
+ finally:
68
+ observer.stop()
69
+ observer.join()