astro-slidev 0.1.0__tar.gz
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.
- astro_slidev-0.1.0/.gitignore +10 -0
- astro_slidev-0.1.0/LICENSE +21 -0
- astro_slidev-0.1.0/PKG-INFO +128 -0
- astro_slidev-0.1.0/README.md +97 -0
- astro_slidev-0.1.0/pyproject.toml +54 -0
- astro_slidev-0.1.0/src/astro_slidev/__init__.py +3 -0
- astro_slidev-0.1.0/src/astro_slidev/assets.py +114 -0
- astro_slidev-0.1.0/src/astro_slidev/cli.py +212 -0
- astro_slidev-0.1.0/src/astro_slidev/config.py +84 -0
- astro_slidev-0.1.0/src/astro_slidev/global-bottom.vue +13 -0
- astro_slidev-0.1.0/src/astro_slidev/interactive.py +109 -0
- astro_slidev-0.1.0/src/astro_slidev/layouts/image-caption.vue +48 -0
- astro_slidev-0.1.0/src/astro_slidev/models/__init__.py +0 -0
- astro_slidev-0.1.0/src/astro_slidev/models/article.py +97 -0
- astro_slidev-0.1.0/src/astro_slidev/models/slide.py +116 -0
- astro_slidev-0.1.0/src/astro_slidev/parser/__init__.py +0 -0
- astro_slidev-0.1.0/src/astro_slidev/parser/frontmatter.py +76 -0
- astro_slidev-0.1.0/src/astro_slidev/parser/markdown_parser.py +300 -0
- astro_slidev-0.1.0/src/astro_slidev/pick.py +41 -0
- astro_slidev-0.1.0/src/astro_slidev/planner/__init__.py +0 -0
- astro_slidev-0.1.0/src/astro_slidev/planner/planner.py +53 -0
- astro_slidev-0.1.0/src/astro_slidev/planner/rules.py +484 -0
- astro_slidev-0.1.0/src/astro_slidev/renderer/__init__.py +0 -0
- astro_slidev-0.1.0/src/astro_slidev/renderer/slidev_renderer.py +357 -0
- astro_slidev-0.1.0/src/astro_slidev/renderer/theme_map.py +30 -0
- astro_slidev-0.1.0/src/astro_slidev/slides.config.yaml +51 -0
- astro_slidev-0.1.0/src/astro_slidev/slug.py +32 -0
- astro_slidev-0.1.0/src/astro_slidev/styles/index.css +159 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sidali Assoul
|
|
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,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: astro-slidev
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Turn an Astro content-collection blog post into a Slidev presentation deck — deterministic, no LLM.
|
|
5
|
+
Project-URL: Homepage, https://github.com/stormsidali2001/astro-slidev
|
|
6
|
+
Project-URL: Repository, https://github.com/stormsidali2001/astro-slidev
|
|
7
|
+
Project-URL: Issues, https://github.com/stormsidali2001/astro-slidev/issues
|
|
8
|
+
Author: Sidali Assoul
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
21
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: markdown-it-py>=3.0.0
|
|
24
|
+
Requires-Dist: mdit-py-plugins>=0.4.0
|
|
25
|
+
Requires-Dist: pillow>=10.0.0
|
|
26
|
+
Requires-Dist: pyyaml>=6.0
|
|
27
|
+
Requires-Dist: questionary>=2.0.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# astro-slidev
|
|
33
|
+
|
|
34
|
+
Turn an Astro content-collection blog post into a [Slidev](https://sli.dev) presentation deck — **deterministic, no LLM**. The same post always produces the same deck: a rule-based planner decides slide breaks, code grouping, table chunking, and layout, so you get a reproducible result you can tweak and re-run, not a one-off AI summary.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install astro-slidev
|
|
38
|
+
cd my-astro-project
|
|
39
|
+
astro-slidev build src/content/blog/my-post.md
|
|
40
|
+
# -> .astro-slidev-build/my-post/slide.md (+ assets/, layouts/, styles/)
|
|
41
|
+
|
|
42
|
+
npx @slidev/cli dev .astro-slidev-build/my-post/slide.md
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## What it does
|
|
46
|
+
|
|
47
|
+
- Parses a post's Markdown (frontmatter + body) into an intermediate representation, never touching your live site's content.
|
|
48
|
+
- Plans a deck: groups short paragraphs and short code blocks onto shared slides instead of one-block-per-slide, isolates long code blocks, chunks oversized tables across continuation slides (so a 30-row table doesn't silently run off the bottom of a slide), and gives every code+list pairing a two-column "explanation" layout when it's a genuine line-by-line walkthrough.
|
|
49
|
+
- Renders real Slidev markdown: a cover slide (title/description/date/tags), a section-divider slide per H2/H3, and content slides — plus an agenda and summary slide built from your headings.
|
|
50
|
+
- Handles images (colocated with the post, downscaled if oversized), GFM tables, KaTeX math (`$...$` / `$$...$$`, passed through untouched), blockquote-style callouts (`> **Note:** ...`), and speaker notes (`<!-- note: ... -->`, invisible on your live site, shown in Slidev's presenter view).
|
|
51
|
+
- Ships a small, optional accent-color theme (kicker bars, tag pills, tinted table headers) that stays off by default — a fresh install produces a plain, unbranded deck using Slidev's own theme.
|
|
52
|
+
|
|
53
|
+
## Why not just ask an LLM to write the slides?
|
|
54
|
+
|
|
55
|
+
You can, and for a one-off talk that's often faster. This exists for the opposite case: you maintain a blog with dozens of posts and want a repeatable, reviewable way to turn any of them into a deck — one whose output you can diff, whose grouping rules you can tune once for your whole content style, and that never silently changes when you rerun it.
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install astro-slidev
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Requires Python 3.10+. `astro-slidev` is a pure-Python CLI — it does **not** install or invoke Slidev/Node for you. Generate the deck, then run Slidev yourself:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npx @slidev/cli dev <output-dir>/slide.md # live preview
|
|
67
|
+
npx @slidev/cli build <output-dir>/slide.md --out dist/slides # static export
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Build a specific post (path or bare slug, resolved against content_dirs)
|
|
74
|
+
astro-slidev build src/content/blog/my-post.md
|
|
75
|
+
astro-slidev build my-post
|
|
76
|
+
|
|
77
|
+
# No post given -> pick one interactively (series, then post)
|
|
78
|
+
astro-slidev build
|
|
79
|
+
|
|
80
|
+
# Override output directory / config file
|
|
81
|
+
astro-slidev build my-post --out dist/deck --config ./my-slides.yaml
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`astro-slidev build` always runs from your project root (the directory you invoke it from) and writes exactly one line to stdout: the path to the generated deck directory. Everything else (progress, the follow-up Slidev commands) goes to stderr, so it composes cleanly with shell capture:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npx @slidev/cli dev "$(astro-slidev build my-post)/slide.md"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Configuration
|
|
91
|
+
|
|
92
|
+
Drop a `slides.config.yaml` in your project root (picked up automatically) or pass `--config path/to/file.yaml`. You only need to list the keys you want to override — see [`src/astro_slidev/slides.config.yaml`](src/astro_slidev/slides.config.yaml) for the full set of defaults with comments.
|
|
93
|
+
|
|
94
|
+
| Key | Default | What it controls |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `content_dirs` | `["src/content/blog"]` | Where the interactive picker looks, and where a bare slug resolves against. Add more entries for a multi-collection or multi-locale project. |
|
|
97
|
+
| `max_words_per_slide` | `90` | Budget for packing paragraphs/explanations onto one slide. |
|
|
98
|
+
| `max_bullets_per_slide` | `6` | Max bullets before a list splits into continuation slides. |
|
|
99
|
+
| `max_code_blocks_per_slide` | `3` | Max consecutive short code blocks grouped onto one slide. |
|
|
100
|
+
| `max_lines_per_code_block` | `25` | A code block longer than this always gets its own slide. |
|
|
101
|
+
| `max_rows_per_slide` | `5` | Max table rows before it splits into continuation slides (header repeated). |
|
|
102
|
+
| `code_layout` | `two-cols` | Layout for a code block with an attached line-by-line explanation list. |
|
|
103
|
+
| `max_image_dimension` | `2000` | Images wider/taller than this (px) get downscaled on copy. |
|
|
104
|
+
| `include_agenda` / `include_summary` | `true` | Whether to generate an agenda slide after the cover and a summary slide at the end. |
|
|
105
|
+
| `font_sans` / `font_mono` / `accent_color` | unset | Opt-in branding — set these to match your site's design system (e.g. `accent_color: "#2563EB"`). Unset means a plain Slidev deck with no custom theming. |
|
|
106
|
+
|
|
107
|
+
## Supported post conventions
|
|
108
|
+
|
|
109
|
+
- **Frontmatter**: only `title` is required. `description`, `pubDate`, `tags`, `series`, `seriesOrder` are all optional and degrade gracefully (no subtitle/date/pill row on the cover slide if absent).
|
|
110
|
+
- **Headings**: body content should use `##`/`###` (H2/H3) — H2 becomes a section divider, H3 a subsection. A post with no headings at all still works (everything lands on one section).
|
|
111
|
+
- **Images**: ``, resolved relative to the post's own file — the standard Astro content-collection colocated-image convention.
|
|
112
|
+
- **Callouts**: an informal blockquote with a bold lead-in, e.g. `> **Note:** ...` / `> **Warning:** ...` / `> **Tip:** ...`.
|
|
113
|
+
- **Speaker notes**: `<!-- note: mention the live demo -->` (single- or multi-line) anywhere in the body — invisible on your live site, shown in Slidev's presenter view on whichever slide the following content lands on.
|
|
114
|
+
- **Math**: `$...$` and `$$...$$`, passed through verbatim for Slidev's built-in KaTeX rendering.
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
git clone https://github.com/stormsidali2001/astro-slidev
|
|
120
|
+
cd astro-slidev
|
|
121
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
122
|
+
pip install -e ".[dev]"
|
|
123
|
+
pytest
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# astro-slidev
|
|
2
|
+
|
|
3
|
+
Turn an Astro content-collection blog post into a [Slidev](https://sli.dev) presentation deck — **deterministic, no LLM**. The same post always produces the same deck: a rule-based planner decides slide breaks, code grouping, table chunking, and layout, so you get a reproducible result you can tweak and re-run, not a one-off AI summary.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install astro-slidev
|
|
7
|
+
cd my-astro-project
|
|
8
|
+
astro-slidev build src/content/blog/my-post.md
|
|
9
|
+
# -> .astro-slidev-build/my-post/slide.md (+ assets/, layouts/, styles/)
|
|
10
|
+
|
|
11
|
+
npx @slidev/cli dev .astro-slidev-build/my-post/slide.md
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## What it does
|
|
15
|
+
|
|
16
|
+
- Parses a post's Markdown (frontmatter + body) into an intermediate representation, never touching your live site's content.
|
|
17
|
+
- Plans a deck: groups short paragraphs and short code blocks onto shared slides instead of one-block-per-slide, isolates long code blocks, chunks oversized tables across continuation slides (so a 30-row table doesn't silently run off the bottom of a slide), and gives every code+list pairing a two-column "explanation" layout when it's a genuine line-by-line walkthrough.
|
|
18
|
+
- Renders real Slidev markdown: a cover slide (title/description/date/tags), a section-divider slide per H2/H3, and content slides — plus an agenda and summary slide built from your headings.
|
|
19
|
+
- Handles images (colocated with the post, downscaled if oversized), GFM tables, KaTeX math (`$...$` / `$$...$$`, passed through untouched), blockquote-style callouts (`> **Note:** ...`), and speaker notes (`<!-- note: ... -->`, invisible on your live site, shown in Slidev's presenter view).
|
|
20
|
+
- Ships a small, optional accent-color theme (kicker bars, tag pills, tinted table headers) that stays off by default — a fresh install produces a plain, unbranded deck using Slidev's own theme.
|
|
21
|
+
|
|
22
|
+
## Why not just ask an LLM to write the slides?
|
|
23
|
+
|
|
24
|
+
You can, and for a one-off talk that's often faster. This exists for the opposite case: you maintain a blog with dozens of posts and want a repeatable, reviewable way to turn any of them into a deck — one whose output you can diff, whose grouping rules you can tune once for your whole content style, and that never silently changes when you rerun it.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install astro-slidev
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Python 3.10+. `astro-slidev` is a pure-Python CLI — it does **not** install or invoke Slidev/Node for you. Generate the deck, then run Slidev yourself:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx @slidev/cli dev <output-dir>/slide.md # live preview
|
|
36
|
+
npx @slidev/cli build <output-dir>/slide.md --out dist/slides # static export
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Build a specific post (path or bare slug, resolved against content_dirs)
|
|
43
|
+
astro-slidev build src/content/blog/my-post.md
|
|
44
|
+
astro-slidev build my-post
|
|
45
|
+
|
|
46
|
+
# No post given -> pick one interactively (series, then post)
|
|
47
|
+
astro-slidev build
|
|
48
|
+
|
|
49
|
+
# Override output directory / config file
|
|
50
|
+
astro-slidev build my-post --out dist/deck --config ./my-slides.yaml
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`astro-slidev build` always runs from your project root (the directory you invoke it from) and writes exactly one line to stdout: the path to the generated deck directory. Everything else (progress, the follow-up Slidev commands) goes to stderr, so it composes cleanly with shell capture:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx @slidev/cli dev "$(astro-slidev build my-post)/slide.md"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
Drop a `slides.config.yaml` in your project root (picked up automatically) or pass `--config path/to/file.yaml`. You only need to list the keys you want to override — see [`src/astro_slidev/slides.config.yaml`](src/astro_slidev/slides.config.yaml) for the full set of defaults with comments.
|
|
62
|
+
|
|
63
|
+
| Key | Default | What it controls |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `content_dirs` | `["src/content/blog"]` | Where the interactive picker looks, and where a bare slug resolves against. Add more entries for a multi-collection or multi-locale project. |
|
|
66
|
+
| `max_words_per_slide` | `90` | Budget for packing paragraphs/explanations onto one slide. |
|
|
67
|
+
| `max_bullets_per_slide` | `6` | Max bullets before a list splits into continuation slides. |
|
|
68
|
+
| `max_code_blocks_per_slide` | `3` | Max consecutive short code blocks grouped onto one slide. |
|
|
69
|
+
| `max_lines_per_code_block` | `25` | A code block longer than this always gets its own slide. |
|
|
70
|
+
| `max_rows_per_slide` | `5` | Max table rows before it splits into continuation slides (header repeated). |
|
|
71
|
+
| `code_layout` | `two-cols` | Layout for a code block with an attached line-by-line explanation list. |
|
|
72
|
+
| `max_image_dimension` | `2000` | Images wider/taller than this (px) get downscaled on copy. |
|
|
73
|
+
| `include_agenda` / `include_summary` | `true` | Whether to generate an agenda slide after the cover and a summary slide at the end. |
|
|
74
|
+
| `font_sans` / `font_mono` / `accent_color` | unset | Opt-in branding — set these to match your site's design system (e.g. `accent_color: "#2563EB"`). Unset means a plain Slidev deck with no custom theming. |
|
|
75
|
+
|
|
76
|
+
## Supported post conventions
|
|
77
|
+
|
|
78
|
+
- **Frontmatter**: only `title` is required. `description`, `pubDate`, `tags`, `series`, `seriesOrder` are all optional and degrade gracefully (no subtitle/date/pill row on the cover slide if absent).
|
|
79
|
+
- **Headings**: body content should use `##`/`###` (H2/H3) — H2 becomes a section divider, H3 a subsection. A post with no headings at all still works (everything lands on one section).
|
|
80
|
+
- **Images**: ``, resolved relative to the post's own file — the standard Astro content-collection colocated-image convention.
|
|
81
|
+
- **Callouts**: an informal blockquote with a bold lead-in, e.g. `> **Note:** ...` / `> **Warning:** ...` / `> **Tip:** ...`.
|
|
82
|
+
- **Speaker notes**: `<!-- note: mention the live demo -->` (single- or multi-line) anywhere in the body — invisible on your live site, shown in Slidev's presenter view on whichever slide the following content lands on.
|
|
83
|
+
- **Math**: `$...$` and `$$...$$`, passed through verbatim for Slidev's built-in KaTeX rendering.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
git clone https://github.com/stormsidali2001/astro-slidev
|
|
89
|
+
cd astro-slidev
|
|
90
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
91
|
+
pip install -e ".[dev]"
|
|
92
|
+
pytest
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "astro-slidev"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Turn an Astro content-collection blog post into a Slidev presentation deck — deterministic, no LLM."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "Sidali Assoul" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"markdown-it-py>=3.0.0",
|
|
15
|
+
"mdit-py-plugins>=0.4.0",
|
|
16
|
+
"PyYAML>=6.0",
|
|
17
|
+
"questionary>=2.0.0",
|
|
18
|
+
"Pillow>=10.0.0",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Environment :: Console",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Text Processing :: Markup :: Markdown",
|
|
31
|
+
"Topic :: Software Development :: Documentation",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/stormsidali2001/astro-slidev"
|
|
36
|
+
Repository = "https://github.com/stormsidali2001/astro-slidev"
|
|
37
|
+
Issues = "https://github.com/stormsidali2001/astro-slidev/issues"
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
astro-slidev = "astro_slidev.cli:main"
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
dev = ["pytest>=8.0.0"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/astro_slidev"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build]
|
|
49
|
+
include = [
|
|
50
|
+
"src/astro_slidev/**/*.py",
|
|
51
|
+
"src/astro_slidev/**/*.vue",
|
|
52
|
+
"src/astro_slidev/**/*.css",
|
|
53
|
+
"src/astro_slidev/**/*.yaml",
|
|
54
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Copies images referenced by a post into the Slidev build's public/assets/
|
|
2
|
+
dir.
|
|
3
|
+
|
|
4
|
+
Images are resolved relative to the post's own folder — the standard Astro
|
|
5
|
+
content-collection colocated-image convention (e.g. ``
|
|
6
|
+
sitting next to the post file) — never relative to the project root or via
|
|
7
|
+
Astro's `image()` schema helper.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from PIL import Image as PILImage
|
|
16
|
+
|
|
17
|
+
from .models.article import Article, Image, Section
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def collect_images(sections: list[Section]) -> list[Image]:
|
|
21
|
+
"""Walks the section tree in document order, returning every Image
|
|
22
|
+
block found. Used to build copy_assets' input list so first-appearance
|
|
23
|
+
collision-naming order matches the article's actual reading order.
|
|
24
|
+
"""
|
|
25
|
+
images: list[Image] = []
|
|
26
|
+
for section in sections:
|
|
27
|
+
images.extend(b for b in section.blocks if isinstance(b, Image))
|
|
28
|
+
images.extend(collect_images(section.children))
|
|
29
|
+
return images
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_image_path(article: Article, image: Image) -> Path:
|
|
33
|
+
return (article.source_dir / image.src).resolve()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _place_image(src_path: Path, dest_path: Path, max_dimension: int) -> None:
|
|
37
|
+
"""Copies `src_path` to `dest_path`, downscaling first if either
|
|
38
|
+
dimension exceeds `max_dimension`. Some images get saved at export/
|
|
39
|
+
retina resolution (far beyond what a slide ever displays them at) —
|
|
40
|
+
shipping them unscaled makes a dev server slow to load and bloats the
|
|
41
|
+
exported deck for no visual benefit. Images already within budget are
|
|
42
|
+
copied verbatim (no re-encoding, no quality loss).
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
with PILImage.open(src_path) as img:
|
|
46
|
+
width, height = img.size
|
|
47
|
+
if width <= max_dimension and height <= max_dimension:
|
|
48
|
+
shutil.copy2(src_path, dest_path)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
scale = max_dimension / max(width, height)
|
|
52
|
+
new_size = (max(1, round(width * scale)), max(1, round(height * scale)))
|
|
53
|
+
resized = img.convert("RGBA") if img.mode in ("P", "RGBA", "LA") else img.convert("RGB")
|
|
54
|
+
resized = resized.resize(new_size, PILImage.LANCZOS)
|
|
55
|
+
save_kwargs = {"optimize": True}
|
|
56
|
+
if resized.mode == "RGB" and dest_path.suffix.lower() in (".jpg", ".jpeg"):
|
|
57
|
+
save_kwargs["quality"] = 85
|
|
58
|
+
resized.save(dest_path, **save_kwargs)
|
|
59
|
+
except PILImage.UnidentifiedImageError:
|
|
60
|
+
shutil.copy2(src_path, dest_path)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def copy_assets(
|
|
64
|
+
article: Article, images: list[Image], build_dir: Path, max_image_dimension: int = 2000
|
|
65
|
+
) -> dict[str, str]:
|
|
66
|
+
"""Copies each resolved image into build_dir/public/assets/, downscaling
|
|
67
|
+
oversized ones (see _place_image). Returns a mapping from the original
|
|
68
|
+
Image.src string to a root-absolute deck path (e.g. "/assets/chart.png").
|
|
69
|
+
|
|
70
|
+
Vite's public/ convention copies these files verbatim under their
|
|
71
|
+
original name and never hash-renames them — required here because our
|
|
72
|
+
renderer references images via a runtime string prop (see
|
|
73
|
+
layouts/image-caption.vue), not a statically-analyzable import, so a
|
|
74
|
+
hash-renamed file's new name would never get reflected in that
|
|
75
|
+
reference (a plain build_dir/assets/ copy gets hash-renamed by `slidev
|
|
76
|
+
build` while the rendered page keeps requesting the original, un-hashed
|
|
77
|
+
filename, producing a 404 in the exported deck).
|
|
78
|
+
|
|
79
|
+
Basename collisions between distinct source paths are disambiguated
|
|
80
|
+
deterministically by first-appearance order in `images` (not filesystem
|
|
81
|
+
iteration order, which isn't guaranteed stable): the second distinct
|
|
82
|
+
source sharing a basename becomes "name-2.ext", the third "name-3.ext",
|
|
83
|
+
etc. A repeated identical `src` is copied once and reused.
|
|
84
|
+
"""
|
|
85
|
+
assets_dir = build_dir / "public" / "assets"
|
|
86
|
+
assets_dir.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
mapping: dict[str, str] = {}
|
|
89
|
+
next_suffix: dict[str, int] = {} # basename -> next collision suffix
|
|
90
|
+
|
|
91
|
+
for image in images:
|
|
92
|
+
src = image.src
|
|
93
|
+
if src in mapping:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
resolved = resolve_image_path(article, image)
|
|
97
|
+
if not resolved.exists():
|
|
98
|
+
raise FileNotFoundError(
|
|
99
|
+
f"Image not found: '{src}' (referenced in {article.source_path.name}, "
|
|
100
|
+
f"resolved to {resolved})"
|
|
101
|
+
)
|
|
102
|
+
basename = resolved.name
|
|
103
|
+
|
|
104
|
+
if basename not in next_suffix:
|
|
105
|
+
next_suffix[basename] = 1
|
|
106
|
+
dest_name = basename
|
|
107
|
+
else:
|
|
108
|
+
next_suffix[basename] += 1
|
|
109
|
+
dest_name = f"{resolved.stem}-{next_suffix[basename]}{resolved.suffix}"
|
|
110
|
+
|
|
111
|
+
_place_image(resolved, assets_dir / dest_name, max_image_dimension)
|
|
112
|
+
mapping[src] = f"/assets/{dest_name}"
|
|
113
|
+
|
|
114
|
+
return mapping
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLI entrypoint for astro-slidev.
|
|
3
|
+
|
|
4
|
+
astro-slidev build <path-or-slug> [--out DIR] [--config FILE]
|
|
5
|
+
astro-slidev build # no post given -> pick interactively
|
|
6
|
+
|
|
7
|
+
Only `build` writes the deck's output-directory path to stdout (and
|
|
8
|
+
*only* that — everything else goes to stderr), so it composes cleanly with
|
|
9
|
+
shell capture: `slidev dev "$(astro-slidev build my-post)/slide.md"`.
|
|
10
|
+
|
|
11
|
+
The interactive picker runs in-process (not a separate subcommand whose
|
|
12
|
+
output you'd capture) deliberately: prompt_toolkit falls back to a
|
|
13
|
+
non-interactive plain-text renderer whenever its stdout isn't a real TTY
|
|
14
|
+
(confirmed via prompt_toolkit's own `create_output`), which is exactly what
|
|
15
|
+
capturing a *separate* pick step's stdout would trigger. Picking and
|
|
16
|
+
building in the same process sidesteps that: the interactive UI runs against
|
|
17
|
+
a real, unredirected terminal, and only the final build directory — printed
|
|
18
|
+
after the picker has already closed — ever goes to stdout.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import difflib
|
|
25
|
+
import shutil
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from .assets import collect_images, copy_assets
|
|
30
|
+
from .config import DEFAULT_CONFIG_PATH, SlidesConfig
|
|
31
|
+
from .models.slide import ImageSlide
|
|
32
|
+
from .parser.markdown_parser import parse_article
|
|
33
|
+
from .planner.planner import plan_deck
|
|
34
|
+
from .renderer.slidev_renderer import render_deck
|
|
35
|
+
from .slug import slug_from_filename
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _log(*args) -> None:
|
|
39
|
+
print(*args, file=sys.stderr)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_config_path(root: Path, override: Path | None) -> Path:
|
|
43
|
+
if override is not None:
|
|
44
|
+
return override
|
|
45
|
+
project_config = root / "slides.config.yaml"
|
|
46
|
+
return project_config if project_config.exists() else DEFAULT_CONFIG_PATH
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _candidate_posts(root: Path, content_dirs: list[str]) -> list[Path]:
|
|
50
|
+
posts: list[Path] = []
|
|
51
|
+
for rel in content_dirs:
|
|
52
|
+
base = root / rel
|
|
53
|
+
if base.exists():
|
|
54
|
+
posts.extend(sorted(base.rglob("*.md")))
|
|
55
|
+
posts.extend(sorted(base.rglob("*.mdx")))
|
|
56
|
+
return posts
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _relative_slug(post: Path, root: Path, content_dirs: list[str]) -> str:
|
|
60
|
+
"""Folder-relative slug, e.g. 'my-series/my-post' (series folder kept,
|
|
61
|
+
numeric ordering prefix and extension stripped).
|
|
62
|
+
"""
|
|
63
|
+
for rel in content_dirs:
|
|
64
|
+
base = root / rel
|
|
65
|
+
try:
|
|
66
|
+
rel_path = post.relative_to(base)
|
|
67
|
+
except ValueError:
|
|
68
|
+
continue
|
|
69
|
+
parts = list(rel_path.parts[:-1]) + [slug_from_filename(rel_path.parts[-1])]
|
|
70
|
+
return "/".join(parts)
|
|
71
|
+
return slug_from_filename(post.name)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_post_path(arg: str, root: Path, content_dirs: list[str]) -> Path:
|
|
75
|
+
"""Accepts an absolute path, a path relative to `root`, or a bare/
|
|
76
|
+
folder-relative slug (with or without a numeric ordering prefix, with or
|
|
77
|
+
without the extension), resolved against `content_dirs`.
|
|
78
|
+
"""
|
|
79
|
+
candidate = Path(arg)
|
|
80
|
+
if candidate.is_absolute() and candidate.exists():
|
|
81
|
+
return candidate
|
|
82
|
+
if (root / arg).exists():
|
|
83
|
+
return (root / arg).resolve()
|
|
84
|
+
|
|
85
|
+
norm_arg = arg.strip("/")
|
|
86
|
+
parts = norm_arg.split("/")
|
|
87
|
+
parts[-1] = slug_from_filename(parts[-1])
|
|
88
|
+
norm_arg = "/".join(parts)
|
|
89
|
+
|
|
90
|
+
posts = _candidate_posts(root, content_dirs)
|
|
91
|
+
if not posts:
|
|
92
|
+
raise SystemExit(f"No posts found under {', '.join(content_dirs)} in {root}")
|
|
93
|
+
|
|
94
|
+
exact_relative = [p for p in posts if _relative_slug(p, root, content_dirs) == norm_arg]
|
|
95
|
+
if len(exact_relative) == 1:
|
|
96
|
+
return exact_relative[0]
|
|
97
|
+
|
|
98
|
+
bare = parts[-1]
|
|
99
|
+
bare_matches = [p for p in posts if slug_from_filename(p.name) == bare]
|
|
100
|
+
if len(bare_matches) == 1:
|
|
101
|
+
return bare_matches[0]
|
|
102
|
+
if len(bare_matches) > 1:
|
|
103
|
+
options = "\n".join(f" - {_relative_slug(p, root, content_dirs)}" for p in bare_matches)
|
|
104
|
+
raise SystemExit(f"'{arg}' matches multiple posts; qualify with the series folder:\n{options}")
|
|
105
|
+
|
|
106
|
+
all_slugs = [_relative_slug(p, root, content_dirs) for p in posts]
|
|
107
|
+
close = difflib.get_close_matches(norm_arg, all_slugs, n=5)
|
|
108
|
+
hint = ("\nClosest matches:\n" + "\n".join(f" - {c}" for c in close)) if close else ""
|
|
109
|
+
raise SystemExit(f"No post found matching '{arg}'.{hint}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build(post_path: Path, config: SlidesConfig, out_dir: Path) -> Path:
|
|
113
|
+
"""Parses, plans, and renders `post_path` into `out_dir/<slug>/`."""
|
|
114
|
+
_log(f"Parsing {post_path}...")
|
|
115
|
+
article = parse_article(post_path)
|
|
116
|
+
|
|
117
|
+
_log("Planning deck...")
|
|
118
|
+
slides = plan_deck(article, config)
|
|
119
|
+
|
|
120
|
+
build_dir = out_dir / article.slug
|
|
121
|
+
if build_dir.exists():
|
|
122
|
+
shutil.rmtree(build_dir)
|
|
123
|
+
build_dir.mkdir(parents=True)
|
|
124
|
+
|
|
125
|
+
package_dir = Path(__file__).resolve().parent
|
|
126
|
+
shutil.copy2(package_dir / "global-bottom.vue", build_dir / "global-bottom.vue")
|
|
127
|
+
|
|
128
|
+
styles_dir = build_dir / "styles"
|
|
129
|
+
styles_dir.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
shutil.copy2(package_dir / "styles" / "index.css", styles_dir / "index.css")
|
|
131
|
+
|
|
132
|
+
images = collect_images(article.sections)
|
|
133
|
+
if images:
|
|
134
|
+
_log(f"Copying {len(images)} image(s)...")
|
|
135
|
+
mapping = copy_assets(article, images, build_dir, config.max_image_dimension)
|
|
136
|
+
for slide in slides:
|
|
137
|
+
if isinstance(slide, ImageSlide) and slide.src in mapping:
|
|
138
|
+
slide.src = mapping[slide.src]
|
|
139
|
+
|
|
140
|
+
layouts_dir = build_dir / "layouts"
|
|
141
|
+
layouts_dir.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
shutil.copy2(package_dir / "layouts" / "image-caption.vue", layouts_dir / "image-caption.vue")
|
|
143
|
+
|
|
144
|
+
_log("Rendering Slidev markdown...")
|
|
145
|
+
deck_md = render_deck(article, slides, config)
|
|
146
|
+
(build_dir / "slide.md").write_text(deck_md, encoding="utf-8")
|
|
147
|
+
_log(f"{len(slides)} slides -> {build_dir / 'slide.md'}")
|
|
148
|
+
|
|
149
|
+
return build_dir
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _cmd_build(args: argparse.Namespace) -> int:
|
|
153
|
+
root = Path.cwd()
|
|
154
|
+
config = SlidesConfig.load(_resolve_config_path(root, args.config))
|
|
155
|
+
out_dir = args.out or (root / ".astro-slidev-build")
|
|
156
|
+
|
|
157
|
+
if args.post:
|
|
158
|
+
try:
|
|
159
|
+
post_path = resolve_post_path(args.post, root, config.content_dirs)
|
|
160
|
+
except (ValueError, FileNotFoundError) as e:
|
|
161
|
+
_log(f"Error: {e}")
|
|
162
|
+
return 1
|
|
163
|
+
else:
|
|
164
|
+
from .pick import pick_post # deferred: questionary only needed for this path
|
|
165
|
+
|
|
166
|
+
_log("No post specified — pick one:")
|
|
167
|
+
chosen = pick_post(root, config.content_dirs)
|
|
168
|
+
if chosen is None:
|
|
169
|
+
_log("No post selected. Aborting.")
|
|
170
|
+
return 130
|
|
171
|
+
post_path = chosen
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
build_dir = build(post_path, config, out_dir)
|
|
175
|
+
except (ValueError, FileNotFoundError) as e:
|
|
176
|
+
_log(f"Error: {e}")
|
|
177
|
+
return 1
|
|
178
|
+
|
|
179
|
+
print(build_dir)
|
|
180
|
+
_log()
|
|
181
|
+
_log("Done. Next, preview or export it with Slidev:")
|
|
182
|
+
_log(f" npx @slidev/cli dev {build_dir}/slide.md")
|
|
183
|
+
_log(f" npx @slidev/cli build {build_dir}/slide.md --out <target-dir>")
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def main(argv: list[str] | None = None) -> int:
|
|
188
|
+
parser = argparse.ArgumentParser(
|
|
189
|
+
prog="astro-slidev",
|
|
190
|
+
description="Turn an Astro content-collection post into a Slidev presentation deck.",
|
|
191
|
+
)
|
|
192
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
193
|
+
|
|
194
|
+
build_parser = sub.add_parser("build", help="Generate a Slidev deck from a post")
|
|
195
|
+
build_parser.add_argument(
|
|
196
|
+
"post",
|
|
197
|
+
nargs="?",
|
|
198
|
+
default=None,
|
|
199
|
+
help="Path or slug of the post, e.g. my-series/my-post. Omit to pick interactively.",
|
|
200
|
+
)
|
|
201
|
+
build_parser.add_argument("--config", type=Path, default=None, help="Path to a slides.config.yaml override")
|
|
202
|
+
build_parser.add_argument(
|
|
203
|
+
"--out", type=Path, default=None, help="Output directory (default: .astro-slidev-build/ in the cwd)"
|
|
204
|
+
)
|
|
205
|
+
build_parser.set_defaults(func=_cmd_build)
|
|
206
|
+
|
|
207
|
+
args = parser.parse_args(argv)
|
|
208
|
+
return args.func(args)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
raise SystemExit(main())
|