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.
Files changed (28) hide show
  1. astro_slidev-0.1.0/.gitignore +10 -0
  2. astro_slidev-0.1.0/LICENSE +21 -0
  3. astro_slidev-0.1.0/PKG-INFO +128 -0
  4. astro_slidev-0.1.0/README.md +97 -0
  5. astro_slidev-0.1.0/pyproject.toml +54 -0
  6. astro_slidev-0.1.0/src/astro_slidev/__init__.py +3 -0
  7. astro_slidev-0.1.0/src/astro_slidev/assets.py +114 -0
  8. astro_slidev-0.1.0/src/astro_slidev/cli.py +212 -0
  9. astro_slidev-0.1.0/src/astro_slidev/config.py +84 -0
  10. astro_slidev-0.1.0/src/astro_slidev/global-bottom.vue +13 -0
  11. astro_slidev-0.1.0/src/astro_slidev/interactive.py +109 -0
  12. astro_slidev-0.1.0/src/astro_slidev/layouts/image-caption.vue +48 -0
  13. astro_slidev-0.1.0/src/astro_slidev/models/__init__.py +0 -0
  14. astro_slidev-0.1.0/src/astro_slidev/models/article.py +97 -0
  15. astro_slidev-0.1.0/src/astro_slidev/models/slide.py +116 -0
  16. astro_slidev-0.1.0/src/astro_slidev/parser/__init__.py +0 -0
  17. astro_slidev-0.1.0/src/astro_slidev/parser/frontmatter.py +76 -0
  18. astro_slidev-0.1.0/src/astro_slidev/parser/markdown_parser.py +300 -0
  19. astro_slidev-0.1.0/src/astro_slidev/pick.py +41 -0
  20. astro_slidev-0.1.0/src/astro_slidev/planner/__init__.py +0 -0
  21. astro_slidev-0.1.0/src/astro_slidev/planner/planner.py +53 -0
  22. astro_slidev-0.1.0/src/astro_slidev/planner/rules.py +484 -0
  23. astro_slidev-0.1.0/src/astro_slidev/renderer/__init__.py +0 -0
  24. astro_slidev-0.1.0/src/astro_slidev/renderer/slidev_renderer.py +357 -0
  25. astro_slidev-0.1.0/src/astro_slidev/renderer/theme_map.py +30 -0
  26. astro_slidev-0.1.0/src/astro_slidev/slides.config.yaml +51 -0
  27. astro_slidev-0.1.0/src/astro_slidev/slug.py +32 -0
  28. astro_slidev-0.1.0/src/astro_slidev/styles/index.css +159 -0
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .venv/
5
+ venv/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .DS_Store
10
+ .astro-slidev-build/
@@ -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**: `![alt](./chart.png)`, 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**: `![alt](./chart.png)`, 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,3 @@
1
+ """astro-slidev: turn an Astro content-collection post into a Slidev deck."""
2
+
3
+ __version__ = "0.1.0"
@@ -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. `![alt](./chart.png)`
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())