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.
- code_down-2.0.1.dist-info/METADATA +148 -0
- code_down-2.0.1.dist-info/RECORD +19 -0
- code_down-2.0.1.dist-info/WHEEL +5 -0
- code_down-2.0.1.dist-info/entry_points.txt +2 -0
- code_down-2.0.1.dist-info/top_level.txt +1 -0
- codedown/__init__.py +0 -0
- codedown/__main__.py +41 -0
- codedown/assets/themes/base.css +132 -0
- codedown/assets/themes/dark.css +27 -0
- codedown/assets/themes/dark.toml +4 -0
- codedown/assets/themes/light.css +28 -0
- codedown/assets/themes/light.toml +4 -0
- codedown/cli.py +266 -0
- codedown/config.py +48 -0
- codedown/converter.py +36 -0
- codedown/paths.py +5 -0
- codedown/themes.py +67 -0
- codedown/updater.py +135 -0
- codedown/watcher.py +69 -0
|
@@ -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
|
+
[](https://pypi.org/project/code-down/)
|
|
32
|
+
[](https://pypi.org/project/code-down/)
|
|
33
|
+
[](https://github.com/bouajilaProg/CodeDown/blob/main/LICENSE)
|
|
34
|
+
[](https://github.com/bouajilaProg/CodeDown/actions/workflows/release.yml)
|
|
35
|
+
[](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 @@
|
|
|
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,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
|
+
}
|
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
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()
|