mkdocs-zip-bundle-plugin 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.
@@ -0,0 +1,33 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daemonless
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.
22
+
23
+ ---
24
+
25
+ This package bundles JSZip v3.10.1, which includes pako.
26
+
27
+ JSZip is copyright (c) 2009-2016 Stuart Knightley <stuart@stuartk.com>
28
+ Dual licensed under the MIT License or GPLv3.
29
+ Source: https://github.com/Stuk/jszip
30
+
31
+ pako is copyright (c) 2014-2017 by Vitaly Puzrin and Andrei Tuputcyn
32
+ Licensed under the MIT License.
33
+ Source: https://github.com/nodeca/pako
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: mkdocs-zip-bundle-plugin
3
+ Version: 0.1.0
4
+ Summary: A MkDocs plugin to bundle specific code blocks into a downloadable ZIP or raw file.
5
+ Author: Daemonless
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/daemonless/mkdocs-zip-bundle-plugin
8
+ Project-URL: Repository, https://github.com/daemonless/mkdocs-zip-bundle-plugin
9
+ Project-URL: Bug Tracker, https://github.com/daemonless/mkdocs-zip-bundle-plugin/issues
10
+ Keywords: mkdocs,zip,bundle,placeholders,interactive
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: mkdocs>=1.4.0
15
+ Requires-Dist: beautifulsoup4>=4.11.0
16
+ Dynamic: license-file
17
+
18
+ # mkdocs-zip-bundle-plugin
19
+
20
+ A MkDocs plugin that turns code blocks into downloadable files. Tag any code block with a bundle ID and filename — the plugin injects a download button automatically. Works with single files (direct download) or multiple files (ZIP archive).
21
+
22
+ Built to pair with [`mkdocs-placeholder-plugin`](https://github.com/six-two/mkdocs-placeholder-plugin): if your docs use interactive placeholders like `@PORT@`, the downloaded file will contain the user's actual values, not the defaults.
23
+
24
+ ## Features
25
+
26
+ - **Single file downloads** — one code block gets a direct raw file download, no ZIP needed
27
+ - **Multi-file ZIP bundles** — group multiple code blocks into one ZIP with a single button
28
+ - **Placeholder-aware** — captures the live browser state, so user-edited values are included in the download
29
+ - **Nested paths** — use `configs/app.yaml` as a filename to create subdirectories inside the ZIP
30
+ - **Custom button labels** — override auto-generated text per bundle
31
+ - **Self-contained** — ships with JSZip and default styling, no extra dependencies
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install mkdocs-zip-bundle-plugin
37
+ ```
38
+
39
+ Requires Python 3.8+ and MkDocs 1.4+. Download buttons require a modern browser (Chrome, Firefox, Safari, Edge).
40
+
41
+ ## Configuration
42
+
43
+ ```yaml
44
+ plugins:
45
+ - search
46
+ - zip-bundle:
47
+ include_jszip: true # set to false if you already load JSZip elsewhere
48
+ zip_label_suffix: "(.zip)" # appended to multi-file bundle button labels
49
+ ```
50
+
51
+ Also enable the `attr_list` extension so MkDocs can read attributes on code blocks:
52
+
53
+ ```yaml
54
+ markdown_extensions:
55
+ - attr_list
56
+ - pymdownx.superfences # recommended for reliable attribute support
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ Add `data-zip-bundle` and `data-zip-filename` attributes to any fenced code block:
62
+
63
+ ### Single file
64
+
65
+ ```yaml { data-zip-bundle="my-app" data-zip-filename="compose.yaml" }
66
+ services:
67
+ app:
68
+ image: my-image:latest
69
+ ```
70
+
71
+ The plugin injects a **Download compose.yaml** button directly after the code block.
72
+
73
+ ### Multiple files (ZIP)
74
+
75
+ ```yaml { data-zip-bundle="my-app" data-zip-filename="compose.yaml" }
76
+ services:
77
+ app:
78
+ image: my-image:latest
79
+ ```
80
+
81
+ ```bash { data-zip-bundle="my-app" data-zip-filename="setup.sh" }
82
+ mkdir -p /data/app
83
+ ```
84
+
85
+ Both blocks share the same bundle ID. The plugin injects a single **Download My App (.zip)** button after the last block.
86
+
87
+ ### Custom button label
88
+
89
+ ```yaml { data-zip-bundle="my-app" data-zip-filename="compose.yaml" data-zip-label="Download config" }
90
+ ...
91
+ ```
92
+
93
+ ### Force ZIP for a single file
94
+
95
+ ```yaml { data-zip-bundle="my-app" data-zip-filename="compose.yaml" data-zip-force="true" }
96
+ ...
97
+ ```
98
+
99
+ ### Nested directories
100
+
101
+ ```yaml { data-zip-bundle="my-app" data-zip-filename="config/app.yaml" }
102
+ ...
103
+ ```
104
+
105
+ ```bash { data-zip-bundle="my-app" data-zip-filename="scripts/setup.sh" }
106
+ ...
107
+ ```
108
+
109
+ The ZIP will contain `config/app.yaml` and `scripts/setup.sh` preserving the directory structure.
110
+
111
+ ## How it works with placeholders
112
+
113
+ If you use [`mkdocs-placeholder-plugin`](https://github.com/six-two/mkdocs-placeholder-plugin), your docs can have editable values like `@PORT@` or `@DATA_PATH@` that users customize in the browser.
114
+
115
+ When the user clicks a download button from this plugin, the downloaded file contains whatever is currently in the code block — including any values the user has already changed. This makes it possible to offer personalized, copy-paste-ready config files directly from your documentation.
116
+
117
+ ## License
118
+
119
+ MIT — see [LICENSE](LICENSE) for details. Bundles [JSZip](https://github.com/Stuk/jszip) (MIT).
@@ -0,0 +1,102 @@
1
+ # mkdocs-zip-bundle-plugin
2
+
3
+ A MkDocs plugin that turns code blocks into downloadable files. Tag any code block with a bundle ID and filename — the plugin injects a download button automatically. Works with single files (direct download) or multiple files (ZIP archive).
4
+
5
+ Built to pair with [`mkdocs-placeholder-plugin`](https://github.com/six-two/mkdocs-placeholder-plugin): if your docs use interactive placeholders like `@PORT@`, the downloaded file will contain the user's actual values, not the defaults.
6
+
7
+ ## Features
8
+
9
+ - **Single file downloads** — one code block gets a direct raw file download, no ZIP needed
10
+ - **Multi-file ZIP bundles** — group multiple code blocks into one ZIP with a single button
11
+ - **Placeholder-aware** — captures the live browser state, so user-edited values are included in the download
12
+ - **Nested paths** — use `configs/app.yaml` as a filename to create subdirectories inside the ZIP
13
+ - **Custom button labels** — override auto-generated text per bundle
14
+ - **Self-contained** — ships with JSZip and default styling, no extra dependencies
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install mkdocs-zip-bundle-plugin
20
+ ```
21
+
22
+ Requires Python 3.8+ and MkDocs 1.4+. Download buttons require a modern browser (Chrome, Firefox, Safari, Edge).
23
+
24
+ ## Configuration
25
+
26
+ ```yaml
27
+ plugins:
28
+ - search
29
+ - zip-bundle:
30
+ include_jszip: true # set to false if you already load JSZip elsewhere
31
+ zip_label_suffix: "(.zip)" # appended to multi-file bundle button labels
32
+ ```
33
+
34
+ Also enable the `attr_list` extension so MkDocs can read attributes on code blocks:
35
+
36
+ ```yaml
37
+ markdown_extensions:
38
+ - attr_list
39
+ - pymdownx.superfences # recommended for reliable attribute support
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ Add `data-zip-bundle` and `data-zip-filename` attributes to any fenced code block:
45
+
46
+ ### Single file
47
+
48
+ ```yaml { data-zip-bundle="my-app" data-zip-filename="compose.yaml" }
49
+ services:
50
+ app:
51
+ image: my-image:latest
52
+ ```
53
+
54
+ The plugin injects a **Download compose.yaml** button directly after the code block.
55
+
56
+ ### Multiple files (ZIP)
57
+
58
+ ```yaml { data-zip-bundle="my-app" data-zip-filename="compose.yaml" }
59
+ services:
60
+ app:
61
+ image: my-image:latest
62
+ ```
63
+
64
+ ```bash { data-zip-bundle="my-app" data-zip-filename="setup.sh" }
65
+ mkdir -p /data/app
66
+ ```
67
+
68
+ Both blocks share the same bundle ID. The plugin injects a single **Download My App (.zip)** button after the last block.
69
+
70
+ ### Custom button label
71
+
72
+ ```yaml { data-zip-bundle="my-app" data-zip-filename="compose.yaml" data-zip-label="Download config" }
73
+ ...
74
+ ```
75
+
76
+ ### Force ZIP for a single file
77
+
78
+ ```yaml { data-zip-bundle="my-app" data-zip-filename="compose.yaml" data-zip-force="true" }
79
+ ...
80
+ ```
81
+
82
+ ### Nested directories
83
+
84
+ ```yaml { data-zip-bundle="my-app" data-zip-filename="config/app.yaml" }
85
+ ...
86
+ ```
87
+
88
+ ```bash { data-zip-bundle="my-app" data-zip-filename="scripts/setup.sh" }
89
+ ...
90
+ ```
91
+
92
+ The ZIP will contain `config/app.yaml` and `scripts/setup.sh` preserving the directory structure.
93
+
94
+ ## How it works with placeholders
95
+
96
+ If you use [`mkdocs-placeholder-plugin`](https://github.com/six-two/mkdocs-placeholder-plugin), your docs can have editable values like `@PORT@` or `@DATA_PATH@` that users customize in the browser.
97
+
98
+ When the user clicks a download button from this plugin, the downloaded file contains whatever is currently in the code block — including any values the user has already changed. This makes it possible to offer personalized, copy-paste-ready config files directly from your documentation.
99
+
100
+ ## License
101
+
102
+ MIT — see [LICENSE](LICENSE) for details. Bundles [JSZip](https://github.com/Stuk/jszip) (MIT).
@@ -0,0 +1 @@
1
+ # mkdocs-zip-bundle-plugin
@@ -0,0 +1,147 @@
1
+ """
2
+ MkDocs plugin to bundle specific code blocks into downloadable ZIP or raw files.
3
+ """
4
+ import os
5
+ import logging
6
+ import shutil
7
+ import re
8
+ from mkdocs.plugins import BasePlugin
9
+ from mkdocs.config import config_options
10
+ from bs4 import BeautifulSoup
11
+
12
+ log = logging.getLogger('mkdocs.plugins.zip_bundle')
13
+
14
+ class ZipBundlePlugin(BasePlugin):
15
+ """
16
+ Plugin class for bundling code blocks.
17
+ """
18
+ config_scheme = (
19
+ ('include_jszip', config_options.Type(bool, default=True)),
20
+ ('zip_label_suffix', config_options.Type(str, default="(.zip)")),
21
+ )
22
+
23
+ def on_config(self, config):
24
+ """
25
+ Automatically add our JS and CSS to the project configuration.
26
+ """
27
+ if self.config['include_jszip']:
28
+ js_jszip = 'javascripts/jszip.min.js'
29
+ if js_jszip not in config['extra_javascript']:
30
+ config['extra_javascript'].append(js_jszip)
31
+
32
+ js_bundle = 'javascripts/zip-bundle.js'
33
+ if js_bundle not in config['extra_javascript']:
34
+ config['extra_javascript'].append(js_bundle)
35
+
36
+ css_bundle = 'css/zip-bundle.css'
37
+ if css_bundle not in config['extra_css']:
38
+ config['extra_css'].append(css_bundle)
39
+
40
+ return config
41
+
42
+ def _sanitize_filename(self, filename):
43
+ """
44
+ Sanitize filename to prevent path traversal while allowing subdirectories.
45
+ """
46
+ # Replace backslashes with forward slashes for ZIP compatibility
47
+ filename = filename.replace('\\', '/')
48
+ # Remove any ".." segments to prevent traversal
49
+ filename = re.sub(r'\.\.(?=/|$)', '', filename)
50
+ # Remove any leading slashes
51
+ filename = re.sub(r'^/+', '', filename)
52
+ # Collapse multiple slashes
53
+ filename = re.sub(r'/+', '/', filename)
54
+ return filename.strip()
55
+
56
+ def _create_button(self, soup, bundle_id, elements):
57
+ """
58
+ Create the download button for a bundle.
59
+ """
60
+ btn = soup.new_tag("button")
61
+ btn['class'] = "md-button zip-bundle-btn"
62
+ btn['data-bundle-id'] = bundle_id
63
+ btn['type'] = "button"
64
+ btn['aria-label'] = f"Download {bundle_id.replace('-', ' ')} bundle"
65
+
66
+ # Check for custom label on ANY element in the bundle (first one wins)
67
+ custom_label = next((el.get('data-zip-label') for el in elements if el.get('data-zip-label')), None)
68
+ force_zip = any(el.get('data-zip-force') == 'true' for el in elements)
69
+
70
+ if custom_label:
71
+ btn.string = custom_label
72
+ elif len(elements) == 1 and not force_zip:
73
+ filename = elements[0].get('data-zip-filename', 'file')
74
+ # Extract only the base filename for the button label if it's a path
75
+ display_name = os.path.basename(filename)
76
+ btn.string = f"Download {display_name}"
77
+ else:
78
+ label = bundle_id.replace('-', ' ').title()
79
+ suffix = self.config['zip_label_suffix']
80
+ btn.string = f"Download {label} {suffix}".strip()
81
+
82
+ container = soup.new_tag("div")
83
+ container['class'] = "zip-bundle-container"
84
+ container.append(btn)
85
+ return container
86
+
87
+ def on_page_content(self, html, page, config, files):
88
+ """
89
+ Inject download buttons into the page content.
90
+ """
91
+ if "data-zip-bundle" not in html:
92
+ return html
93
+
94
+ soup = BeautifulSoup(html, 'html.parser')
95
+
96
+ # 1. Group all elements by their bundle ID
97
+ bundles = {}
98
+ for el in soup.find_all(attrs={"data-zip-bundle": True}):
99
+ bundle_id = el['data-zip-bundle']
100
+ if bundle_id not in bundles:
101
+ bundles[bundle_id] = []
102
+
103
+ # Sanitize filename on the element before grouping
104
+ if el.has_attr('data-zip-filename'):
105
+ el['data-zip-filename'] = self._sanitize_filename(el['data-zip-filename'])
106
+
107
+ bundles[bundle_id].append(el)
108
+
109
+ if not bundles:
110
+ return html
111
+
112
+ # 2. For each bundle, inject a download button after the last element
113
+ for bundle_id, elements in bundles.items():
114
+ container = self._create_button(soup, bundle_id, elements)
115
+ elements[-1].insert_after(container)
116
+
117
+ return str(soup)
118
+
119
+ def on_post_build(self, config):
120
+ """
121
+ Copy assets to the site directory after build.
122
+ """
123
+ assets_dir = os.path.join(os.path.dirname(__file__), 'assets')
124
+
125
+ to_copy = ['zip-bundle.js', 'zip-bundle.css']
126
+ if self.config['include_jszip']:
127
+ to_copy.append('jszip.min.js')
128
+
129
+ for filename in to_copy:
130
+ subfolder = 'javascripts' if filename.endswith('.js') else 'css'
131
+ dest_path = os.path.join(config['site_dir'], subfolder, filename)
132
+ src_path = os.path.join(assets_dir, filename)
133
+
134
+ if not os.path.exists(src_path):
135
+ raise FileNotFoundError(
136
+ f"ZipBundle: Asset '{filename}' not found in package at {src_path}. "
137
+ "Your installation may be corrupt — try reinstalling mkdocs-zip-bundle."
138
+ )
139
+
140
+ try:
141
+ os.makedirs(os.path.dirname(dest_path), exist_ok=True)
142
+ shutil.copyfile(src_path, dest_path)
143
+ except OSError as e:
144
+ raise OSError(
145
+ f"ZipBundle: Failed to copy '{filename}' to {dest_path}: {e}. "
146
+ "Downloads will not work in the built site."
147
+ ) from e
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: mkdocs-zip-bundle-plugin
3
+ Version: 0.1.0
4
+ Summary: A MkDocs plugin to bundle specific code blocks into a downloadable ZIP or raw file.
5
+ Author: Daemonless
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/daemonless/mkdocs-zip-bundle-plugin
8
+ Project-URL: Repository, https://github.com/daemonless/mkdocs-zip-bundle-plugin
9
+ Project-URL: Bug Tracker, https://github.com/daemonless/mkdocs-zip-bundle-plugin/issues
10
+ Keywords: mkdocs,zip,bundle,placeholders,interactive
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: mkdocs>=1.4.0
15
+ Requires-Dist: beautifulsoup4>=4.11.0
16
+ Dynamic: license-file
17
+
18
+ # mkdocs-zip-bundle-plugin
19
+
20
+ A MkDocs plugin that turns code blocks into downloadable files. Tag any code block with a bundle ID and filename — the plugin injects a download button automatically. Works with single files (direct download) or multiple files (ZIP archive).
21
+
22
+ Built to pair with [`mkdocs-placeholder-plugin`](https://github.com/six-two/mkdocs-placeholder-plugin): if your docs use interactive placeholders like `@PORT@`, the downloaded file will contain the user's actual values, not the defaults.
23
+
24
+ ## Features
25
+
26
+ - **Single file downloads** — one code block gets a direct raw file download, no ZIP needed
27
+ - **Multi-file ZIP bundles** — group multiple code blocks into one ZIP with a single button
28
+ - **Placeholder-aware** — captures the live browser state, so user-edited values are included in the download
29
+ - **Nested paths** — use `configs/app.yaml` as a filename to create subdirectories inside the ZIP
30
+ - **Custom button labels** — override auto-generated text per bundle
31
+ - **Self-contained** — ships with JSZip and default styling, no extra dependencies
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install mkdocs-zip-bundle-plugin
37
+ ```
38
+
39
+ Requires Python 3.8+ and MkDocs 1.4+. Download buttons require a modern browser (Chrome, Firefox, Safari, Edge).
40
+
41
+ ## Configuration
42
+
43
+ ```yaml
44
+ plugins:
45
+ - search
46
+ - zip-bundle:
47
+ include_jszip: true # set to false if you already load JSZip elsewhere
48
+ zip_label_suffix: "(.zip)" # appended to multi-file bundle button labels
49
+ ```
50
+
51
+ Also enable the `attr_list` extension so MkDocs can read attributes on code blocks:
52
+
53
+ ```yaml
54
+ markdown_extensions:
55
+ - attr_list
56
+ - pymdownx.superfences # recommended for reliable attribute support
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ Add `data-zip-bundle` and `data-zip-filename` attributes to any fenced code block:
62
+
63
+ ### Single file
64
+
65
+ ```yaml { data-zip-bundle="my-app" data-zip-filename="compose.yaml" }
66
+ services:
67
+ app:
68
+ image: my-image:latest
69
+ ```
70
+
71
+ The plugin injects a **Download compose.yaml** button directly after the code block.
72
+
73
+ ### Multiple files (ZIP)
74
+
75
+ ```yaml { data-zip-bundle="my-app" data-zip-filename="compose.yaml" }
76
+ services:
77
+ app:
78
+ image: my-image:latest
79
+ ```
80
+
81
+ ```bash { data-zip-bundle="my-app" data-zip-filename="setup.sh" }
82
+ mkdir -p /data/app
83
+ ```
84
+
85
+ Both blocks share the same bundle ID. The plugin injects a single **Download My App (.zip)** button after the last block.
86
+
87
+ ### Custom button label
88
+
89
+ ```yaml { data-zip-bundle="my-app" data-zip-filename="compose.yaml" data-zip-label="Download config" }
90
+ ...
91
+ ```
92
+
93
+ ### Force ZIP for a single file
94
+
95
+ ```yaml { data-zip-bundle="my-app" data-zip-filename="compose.yaml" data-zip-force="true" }
96
+ ...
97
+ ```
98
+
99
+ ### Nested directories
100
+
101
+ ```yaml { data-zip-bundle="my-app" data-zip-filename="config/app.yaml" }
102
+ ...
103
+ ```
104
+
105
+ ```bash { data-zip-bundle="my-app" data-zip-filename="scripts/setup.sh" }
106
+ ...
107
+ ```
108
+
109
+ The ZIP will contain `config/app.yaml` and `scripts/setup.sh` preserving the directory structure.
110
+
111
+ ## How it works with placeholders
112
+
113
+ If you use [`mkdocs-placeholder-plugin`](https://github.com/six-two/mkdocs-placeholder-plugin), your docs can have editable values like `@PORT@` or `@DATA_PATH@` that users customize in the browser.
114
+
115
+ When the user clicks a download button from this plugin, the downloaded file contains whatever is currently in the code block — including any values the user has already changed. This makes it possible to offer personalized, copy-paste-ready config files directly from your documentation.
116
+
117
+ ## License
118
+
119
+ MIT — see [LICENSE](LICENSE) for details. Bundles [JSZip](https://github.com/Stuk/jszip) (MIT).
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ mkdocs_zip_bundle/__init__.py
5
+ mkdocs_zip_bundle/plugin.py
6
+ mkdocs_zip_bundle_plugin.egg-info/PKG-INFO
7
+ mkdocs_zip_bundle_plugin.egg-info/SOURCES.txt
8
+ mkdocs_zip_bundle_plugin.egg-info/dependency_links.txt
9
+ mkdocs_zip_bundle_plugin.egg-info/entry_points.txt
10
+ mkdocs_zip_bundle_plugin.egg-info/requires.txt
11
+ mkdocs_zip_bundle_plugin.egg-info/top_level.txt
12
+ tests/test_integration.py
13
+ tests/test_plugin.py
@@ -0,0 +1,2 @@
1
+ [mkdocs.plugins]
2
+ zip-bundle = mkdocs_zip_bundle.plugin:ZipBundlePlugin
@@ -0,0 +1,2 @@
1
+ mkdocs>=1.4.0
2
+ beautifulsoup4>=4.11.0
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mkdocs-zip-bundle-plugin"
7
+ version = "0.1.0"
8
+ description = "A MkDocs plugin to bundle specific code blocks into a downloadable ZIP or raw file."
9
+ readme = "README.md"
10
+ authors = [{ name = "Daemonless" }]
11
+ license = { text = "MIT" }
12
+ requires-python = ">=3.8"
13
+ keywords = ["mkdocs", "zip", "bundle", "placeholders", "interactive"]
14
+ dependencies = [
15
+ "mkdocs>=1.4.0",
16
+ "beautifulsoup4>=4.11.0"
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/daemonless/mkdocs-zip-bundle-plugin"
21
+ Repository = "https://github.com/daemonless/mkdocs-zip-bundle-plugin"
22
+ "Bug Tracker" = "https://github.com/daemonless/mkdocs-zip-bundle-plugin/issues"
23
+
24
+ [project.entry-points."mkdocs.plugins"]
25
+ zip-bundle = "mkdocs_zip_bundle.plugin:ZipBundlePlugin"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,56 @@
1
+ import pytest
2
+ from bs4 import BeautifulSoup
3
+ from mkdocs_zip_bundle.plugin import ZipBundlePlugin
4
+
5
+ @pytest.fixture
6
+ def plugin():
7
+ plugin = ZipBundlePlugin()
8
+ plugin.config = {
9
+ 'include_jszip': True,
10
+ 'zip_label_suffix': '(.zip)',
11
+ }
12
+ return plugin
13
+
14
+ def test_on_page_content_tabbed_placement(plugin):
15
+ # MkDocs Material renders fenced code block attributes onto the .highlight wrapper div
16
+ html = """
17
+ <div class="tabbed-set">
18
+ <div class="tabbed-content">
19
+ <div class="tabbed-block">
20
+ <div class="highlight" data-zip-bundle="b1" data-zip-filename="f1.txt">
21
+ <pre><code>content</code></pre>
22
+ </div>
23
+ </div>
24
+ </div>
25
+ </div>
26
+ """
27
+ result = plugin.on_page_content(html, None, None, None)
28
+ soup = BeautifulSoup(result, 'html.parser')
29
+
30
+ # Button should be INSIDE the tabbed-block, right after the highlight div
31
+ tabbed_block = soup.find(class_='tabbed-block')
32
+ button_container = soup.find(class_='zip-bundle-container')
33
+
34
+ assert button_container is not None
35
+ assert button_container.parent == tabbed_block
36
+
37
+ def test_on_page_content_multi_bundle(plugin):
38
+ html = """
39
+ <div data-zip-bundle="b1" data-zip-filename="f1.txt"></div>
40
+ <div data-zip-bundle="b2" data-zip-filename="f2.txt"></div>
41
+ """
42
+ result = plugin.on_page_content(html, None, None, None)
43
+ soup = BeautifulSoup(result, 'html.parser')
44
+
45
+ buttons = soup.find_all(class_='zip-bundle-btn')
46
+ assert len(buttons) == 2
47
+ assert buttons[0]['data-bundle-id'] == 'b1'
48
+ assert buttons[1]['data-bundle-id'] == 'b2'
49
+
50
+ def test_on_config_asset_injection(plugin):
51
+ config = {'extra_javascript': [], 'extra_css': []}
52
+ result = plugin.on_config(config)
53
+
54
+ assert 'javascripts/jszip.min.js' in result['extra_javascript']
55
+ assert 'javascripts/zip-bundle.js' in result['extra_javascript']
56
+ assert 'css/zip-bundle.css' in result['extra_css']
@@ -0,0 +1,126 @@
1
+ import os
2
+ import shutil
3
+ import tempfile
4
+ import pytest
5
+ from unittest.mock import patch
6
+ from bs4 import BeautifulSoup
7
+ from mkdocs_zip_bundle.plugin import ZipBundlePlugin
8
+
9
+
10
+ @pytest.fixture
11
+ def plugin():
12
+ plugin = ZipBundlePlugin()
13
+ plugin.config = {
14
+ 'include_jszip': True,
15
+ 'zip_label_suffix': '(.zip)',
16
+ }
17
+ return plugin
18
+
19
+
20
+ def make_element(soup, attrs):
21
+ """Create a real BS4 Tag with the given attributes."""
22
+ tag = soup.new_tag("div")
23
+ for k, v in attrs.items():
24
+ tag[k] = v
25
+ return tag
26
+
27
+
28
+ # --- _sanitize_filename ---
29
+
30
+ def test_sanitize_filename(plugin):
31
+ assert plugin._sanitize_filename('test.txt') == 'test.txt'
32
+ assert plugin._sanitize_filename('/abs/path/test.txt') == 'abs/path/test.txt'
33
+ assert plugin._sanitize_filename('../traversal.txt') == 'traversal.txt'
34
+ assert plugin._sanitize_filename('some/../../path.txt') == 'some/path.txt'
35
+ assert plugin._sanitize_filename('win\\path.txt') == 'win/path.txt'
36
+ assert plugin._sanitize_filename('subdir/file.txt') == 'subdir/file.txt'
37
+ assert plugin._sanitize_filename('//double//slash//') == 'double/slash/'
38
+
39
+
40
+ # --- _create_button ---
41
+
42
+ def test_create_button_single(plugin):
43
+ soup = BeautifulSoup('', 'html.parser')
44
+ elements = [make_element(soup, {'data-zip-filename': 'compose.yaml'})]
45
+ container = plugin._create_button(soup, 'my-bundle', elements)
46
+
47
+ button = container.find('button')
48
+ assert button.string == 'Download compose.yaml'
49
+ assert button.get('type') == 'button'
50
+ assert 'onclick' not in button.attrs
51
+
52
+
53
+ def test_create_button_single_subdir(plugin):
54
+ soup = BeautifulSoup('', 'html.parser')
55
+ elements = [make_element(soup, {'data-zip-filename': 'configs/compose.yaml'})]
56
+ container = plugin._create_button(soup, 'my-bundle', elements)
57
+
58
+ button = container.find('button')
59
+ assert button.string == 'Download compose.yaml'
60
+
61
+
62
+ def test_create_button_custom_label(plugin):
63
+ soup = BeautifulSoup('', 'html.parser')
64
+ elements = [make_element(soup, {'data-zip-filename': 'f.txt', 'data-zip-label': 'Grab Files'})]
65
+ container = plugin._create_button(soup, 'my-bundle', elements)
66
+
67
+ button = container.find('button')
68
+ assert button.string == 'Grab Files'
69
+
70
+
71
+ def test_create_button_multi(plugin):
72
+ soup = BeautifulSoup('', 'html.parser')
73
+ elements = [make_element(soup, {}), make_element(soup, {})]
74
+ container = plugin._create_button(soup, 'my-bundle', elements)
75
+
76
+ button = container.find('button')
77
+ assert button.string == 'Download My Bundle (.zip)'
78
+
79
+
80
+ def test_create_button_force_zip_single(plugin):
81
+ """Single file with data-zip-force=true should use ZIP label."""
82
+ soup = BeautifulSoup('', 'html.parser')
83
+ elements = [make_element(soup, {'data-zip-filename': 'f.txt', 'data-zip-force': 'true'})]
84
+ container = plugin._create_button(soup, 'my-bundle', elements)
85
+
86
+ button = container.find('button')
87
+ assert button.string == 'Download My Bundle (.zip)'
88
+
89
+
90
+ # --- on_post_build ---
91
+
92
+ @pytest.fixture
93
+ def assets_dir():
94
+ return os.path.join(os.path.dirname(__file__), '..', 'mkdocs_zip_bundle', 'assets')
95
+
96
+
97
+ def test_on_post_build_copies_assets(plugin, tmp_path):
98
+ config = {'site_dir': str(tmp_path)}
99
+ plugin.on_post_build(config)
100
+
101
+ assert (tmp_path / 'javascripts' / 'jszip.min.js').exists()
102
+ assert (tmp_path / 'javascripts' / 'zip-bundle.js').exists()
103
+ assert (tmp_path / 'css' / 'zip-bundle.css').exists()
104
+
105
+
106
+ def test_on_post_build_skips_jszip_when_disabled(plugin, tmp_path):
107
+ plugin.config['include_jszip'] = False
108
+ config = {'site_dir': str(tmp_path)}
109
+ plugin.on_post_build(config)
110
+
111
+ assert not (tmp_path / 'javascripts' / 'jszip.min.js').exists()
112
+ assert (tmp_path / 'javascripts' / 'zip-bundle.js').exists()
113
+
114
+
115
+ def test_on_post_build_missing_asset_raises(plugin, tmp_path):
116
+ config = {'site_dir': str(tmp_path)}
117
+ with patch('os.path.exists', return_value=False):
118
+ with pytest.raises(FileNotFoundError, match='ZipBundle'):
119
+ plugin.on_post_build(config)
120
+
121
+
122
+ def test_on_post_build_copy_failure_raises(plugin, tmp_path):
123
+ config = {'site_dir': str(tmp_path)}
124
+ with patch('shutil.copyfile', side_effect=OSError("disk full")):
125
+ with pytest.raises(OSError, match='ZipBundle'):
126
+ plugin.on_post_build(config)