mkdocs-zip-bundle-plugin 0.1.1__tar.gz → 0.3.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 (19) hide show
  1. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/PKG-INFO +8 -6
  2. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/README.md +4 -2
  3. mkdocs_zip_bundle_plugin-0.3.0/mkdocs_zip_bundle/assets/zip-bundle.js +172 -0
  4. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/plugin.py +18 -11
  5. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/PKG-INFO +8 -6
  6. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/pyproject.toml +7 -4
  7. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/tests/test_plugin.py +24 -1
  8. mkdocs_zip_bundle_plugin-0.1.1/mkdocs_zip_bundle/assets/zip-bundle.js +0 -67
  9. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/LICENSE +0 -0
  10. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/__init__.py +0 -0
  11. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/assets/jszip.min.js +0 -0
  12. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/assets/zip-bundle.css +0 -0
  13. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/SOURCES.txt +0 -0
  14. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/dependency_links.txt +0 -0
  15. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/entry_points.txt +0 -0
  16. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/requires.txt +0 -0
  17. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/top_level.txt +0 -0
  18. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/setup.cfg +0 -0
  19. {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/tests/test_integration.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs-zip-bundle-plugin
3
- Version: 0.1.1
4
- Summary: A MkDocs plugin to bundle specific code blocks into a downloadable ZIP or raw file.
3
+ Version: 0.3.0
4
+ Summary: Bundle code blocks into downloadable ZIP or raw files — for MkDocs/Material and Zensical.
5
5
  Author: Daemonless
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/daemonless/mkdocs-zip-bundle-plugin
8
8
  Project-URL: Repository, https://github.com/daemonless/mkdocs-zip-bundle-plugin
9
9
  Project-URL: Bug Tracker, https://github.com/daemonless/mkdocs-zip-bundle-plugin/issues
10
- Keywords: mkdocs,zip,bundle,placeholders,interactive
10
+ Keywords: mkdocs,material,zensical,zip,bundle,placeholders,interactive
11
11
  Requires-Python: >=3.8
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
@@ -17,9 +17,11 @@ Dynamic: license-file
17
17
 
18
18
  # mkdocs-zip-bundle-plugin
19
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).
20
+ Turn code blocks into downloadable files — for **MkDocs/Material** and **[Zensical](https://zensical.org)**. Tag any code block with a bundle ID and filename and a download button is injected automatically. Works with single files (direct download) or multiple files (ZIP archive).
21
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.
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. _(MkDocs/Material — see [Using with Zensical](https://mkdocs-zip-bundle-plugin.daemonless.io/configuration/#using-with-zensical) for the Zensical caveat.)_
23
+
24
+ **[Live demo → mkdocs-zip-bundle-plugin.daemonless.io](https://mkdocs-zip-bundle-plugin.daemonless.io)**
23
25
 
24
26
  ## Features
25
27
 
@@ -1,8 +1,10 @@
1
1
  # mkdocs-zip-bundle-plugin
2
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).
3
+ Turn code blocks into downloadable files — for **MkDocs/Material** and **[Zensical](https://zensical.org)**. Tag any code block with a bundle ID and filename and a download button is injected automatically. Works with single files (direct download) or multiple files (ZIP archive).
4
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.
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. _(MkDocs/Material — see [Using with Zensical](https://mkdocs-zip-bundle-plugin.daemonless.io/configuration/#using-with-zensical) for the Zensical caveat.)_
6
+
7
+ **[Live demo → mkdocs-zip-bundle-plugin.daemonless.io](https://mkdocs-zip-bundle-plugin.daemonless.io)**
6
8
 
7
9
  ## Features
8
10
 
@@ -0,0 +1,172 @@
1
+ // ── Button injection ──────────────────────────────────────────────────
2
+ // Mirrors the Python plugin's on_page_content / _create_button so the
3
+ // download buttons exist even where the plugin's build hook never runs
4
+ // (e.g. Zensical). Idempotent: skips any bundle that already has a button,
5
+ // so on MkDocs/Material (server-injected) this is a harmless no-op.
6
+
7
+ function sanitizeFilename(filename) {
8
+ if (!filename) return '';
9
+ // Match plugin._sanitize_filename: prevent path traversal, allow subdirs.
10
+ filename = filename.replace(/\0/g, '').replace(/\\/g, '/');
11
+ const parts = [];
12
+ for (let part of filename.split('/')) {
13
+ part = part.trim();
14
+ // Drop empty parts and segments consisting only of dots (., .., ..., etc.)
15
+ if (!part || /^\.+$/.test(part)) continue;
16
+ // Strip Windows drive letters (e.g. C:)
17
+ if (/^[a-zA-Z]:$/.test(part)) continue;
18
+ parts.push(part);
19
+ }
20
+ return parts.join('/');
21
+ }
22
+
23
+ function titleCase(str) {
24
+ // Python str.title(): capitalize each word, lowercase the rest.
25
+ return str.replace(/\w\S*/g, t => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
26
+ }
27
+
28
+ function buildButtonContainer(bundleId, elements) {
29
+ const cfg = window.zipBundleConfig || {};
30
+ const labelSuffix = cfg.labelSuffix || '(.zip)';
31
+
32
+ const btn = document.createElement('button');
33
+ btn.className = 'md-button zip-bundle-btn';
34
+ btn.setAttribute('data-bundle-id', bundleId);
35
+ btn.type = 'button';
36
+ btn.setAttribute('aria-label', `Download ${bundleId.replace(/-/g, ' ')} bundle`);
37
+
38
+ const customLabel = elements
39
+ .map(el => el.getAttribute('data-zip-label'))
40
+ .find(Boolean);
41
+ const forceZip = elements.some(el => el.getAttribute('data-zip-force') === 'true');
42
+
43
+ if (customLabel) {
44
+ btn.textContent = customLabel;
45
+ } else if (elements.length === 1 && !forceZip) {
46
+ const rawFilename = elements[0].getAttribute('data-zip-filename');
47
+ const filename = sanitizeFilename(rawFilename || '') || 'file';
48
+ const displayName = filename.split('/').pop() || 'file'; // basename
49
+ btn.textContent = `Download ${displayName}`;
50
+ } else {
51
+ const label = titleCase(bundleId.replace(/-/g, ' '));
52
+ btn.textContent = `Download ${label} ${labelSuffix}`.trim();
53
+ }
54
+
55
+ const container = document.createElement('div');
56
+ container.className = 'zip-bundle-container';
57
+ container.appendChild(btn);
58
+ return container;
59
+ }
60
+
61
+ function injectZipButtons() {
62
+ const tagged = document.querySelectorAll('[data-zip-bundle]');
63
+ if (tagged.length === 0) return;
64
+
65
+ // Group elements by bundle id, preserving document order.
66
+ const bundles = new Map();
67
+ tagged.forEach(el => {
68
+ if (el.hasAttribute('data-zip-filename')) {
69
+ el.setAttribute('data-zip-filename',
70
+ sanitizeFilename(el.getAttribute('data-zip-filename')));
71
+ }
72
+ const id = el.getAttribute('data-zip-bundle');
73
+ if (!bundles.has(id)) bundles.set(id, []);
74
+ bundles.get(id).push(el);
75
+ });
76
+
77
+ bundles.forEach((elements, id) => {
78
+ // Idempotent: skip if a button already exists (server-injected or a
79
+ // prior run under instant navigation).
80
+ const sel = `.zip-bundle-btn[data-bundle-id="${CSS.escape(id)}"]`;
81
+ if (document.querySelector(sel)) return;
82
+ const container = buildButtonContainer(id, elements);
83
+ elements[elements.length - 1].insertAdjacentElement('afterend', container);
84
+ });
85
+ }
86
+
87
+ // Run on initial load and on every (instant) navigation. Material/Zensical
88
+ // expose the document$ observable; fall back to DOMContentLoaded elsewhere.
89
+ if (window.document$ && typeof window.document$.subscribe === 'function') {
90
+ window.document$.subscribe(injectZipButtons);
91
+ } else if (document.readyState !== 'loading') {
92
+ injectZipButtons();
93
+ } else {
94
+ document.addEventListener('DOMContentLoaded', injectZipButtons);
95
+ }
96
+
97
+ // ── Download handling ─────────────────────────────────────────────────
98
+
99
+ function extractCodeContent(el) {
100
+ const codeEl = el.querySelector('code') || el;
101
+ const clone = codeEl.cloneNode(true);
102
+ // Strip line numbers, Material code annotations, and clipboard buttons
103
+ clone.querySelectorAll('.linenos, .lineno, .md-annotation, .md-clipboard').forEach(n => n.remove());
104
+ return clone.textContent;
105
+ }
106
+
107
+ document.addEventListener('click', function(e) {
108
+ const btn = e.target.closest('.zip-bundle-btn[data-bundle-id]');
109
+ if (btn) {
110
+ downloadZipBundle(btn.getAttribute('data-bundle-id'));
111
+ }
112
+ });
113
+
114
+ async function downloadZipBundle(bundleId) {
115
+ const escapedId = (window.CSS && CSS.escape) ? CSS.escape(bundleId) : bundleId.replace(/"/g, '\\"');
116
+ const elements = document.querySelectorAll(`[data-zip-bundle="${escapedId}"]`);
117
+
118
+ if (elements.length === 0) {
119
+ console.warn(`No elements found for bundle ID: ${bundleId}`);
120
+ return;
121
+ }
122
+
123
+ const forceZip = Array.from(elements).some(el => el.getAttribute('data-zip-force') === 'true');
124
+
125
+ // If it's only one file AND we aren't forcing a ZIP, download it directly
126
+ if (elements.length === 1 && !forceZip) {
127
+ const el = elements[0];
128
+ const rawFilename = el.getAttribute('data-zip-filename');
129
+ const filename = sanitizeFilename(rawFilename || '') || 'file';
130
+ const content = extractCodeContent(el);
131
+
132
+ // Ensure UTF-8 encoding
133
+ const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
134
+ const url = URL.createObjectURL(blob);
135
+ triggerDownload(url, filename);
136
+ return;
137
+ }
138
+
139
+ // Multiple files (or forced single ZIP)
140
+ if (typeof JSZip === 'undefined') {
141
+ console.error('JSZip is not loaded. Please include it in your mkdocs.yml extra_javascript or enable it in the plugin config.');
142
+ alert('Error: ZIP library not loaded.');
143
+ return;
144
+ }
145
+
146
+ const zip = new JSZip();
147
+ elements.forEach(el => {
148
+ const rawFilename = el.getAttribute('data-zip-filename');
149
+ const filename = sanitizeFilename(rawFilename || '') || 'unnamed-file';
150
+ const content = extractCodeContent(el);
151
+
152
+ zip.file(filename, content, { date: new Date(0) });
153
+ });
154
+
155
+ const blob = await zip.generateAsync({
156
+ type: "blob",
157
+ compression: "DEFLATE",
158
+ compressionOptions: { level: 6 }
159
+ });
160
+ const url = URL.createObjectURL(blob);
161
+ triggerDownload(url, `${bundleId}.zip`);
162
+ }
163
+
164
+ function triggerDownload(url, filename) {
165
+ const a = document.createElement('a');
166
+ a.href = url;
167
+ a.download = filename;
168
+ document.body.appendChild(a);
169
+ a.click();
170
+ document.body.removeChild(a);
171
+ URL.revokeObjectURL(url);
172
+ }
@@ -43,15 +43,22 @@ class ZipBundlePlugin(BasePlugin):
43
43
  """
44
44
  Sanitize filename to prevent path traversal while allowing subdirectories.
45
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()
46
+ if not filename:
47
+ return ""
48
+ # Remove null bytes and normalize slashes
49
+ filename = filename.replace('\0', '').replace('\\', '/')
50
+ # Split into path components and clean each segment
51
+ parts = []
52
+ for part in filename.split('/'):
53
+ part = part.strip()
54
+ # Drop empty segments and any segment consisting only of dots (., .., ...)
55
+ if not part or re.fullmatch(r'\.+', part):
56
+ continue
57
+ # Strip Windows drive letters if leading (e.g. 'C:')
58
+ if len(part) == 2 and part[1] == ':' and part[0].isalpha():
59
+ continue
60
+ parts.append(part)
61
+ return '/'.join(parts)
55
62
 
56
63
  def _create_button(self, soup, bundle_id, elements):
57
64
  """
@@ -70,9 +77,9 @@ class ZipBundlePlugin(BasePlugin):
70
77
  if custom_label:
71
78
  btn.string = custom_label
72
79
  elif len(elements) == 1 and not force_zip:
73
- filename = elements[0].get('data-zip-filename', 'file')
80
+ filename = elements[0].get('data-zip-filename') or 'file'
74
81
  # Extract only the base filename for the button label if it's a path
75
- display_name = os.path.basename(filename)
82
+ display_name = os.path.basename(filename) or 'file'
76
83
  btn.string = f"Download {display_name}"
77
84
  else:
78
85
  label = bundle_id.replace('-', ' ').title()
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs-zip-bundle-plugin
3
- Version: 0.1.1
4
- Summary: A MkDocs plugin to bundle specific code blocks into a downloadable ZIP or raw file.
3
+ Version: 0.3.0
4
+ Summary: Bundle code blocks into downloadable ZIP or raw files — for MkDocs/Material and Zensical.
5
5
  Author: Daemonless
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/daemonless/mkdocs-zip-bundle-plugin
8
8
  Project-URL: Repository, https://github.com/daemonless/mkdocs-zip-bundle-plugin
9
9
  Project-URL: Bug Tracker, https://github.com/daemonless/mkdocs-zip-bundle-plugin/issues
10
- Keywords: mkdocs,zip,bundle,placeholders,interactive
10
+ Keywords: mkdocs,material,zensical,zip,bundle,placeholders,interactive
11
11
  Requires-Python: >=3.8
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
@@ -17,9 +17,11 @@ Dynamic: license-file
17
17
 
18
18
  # mkdocs-zip-bundle-plugin
19
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).
20
+ Turn code blocks into downloadable files — for **MkDocs/Material** and **[Zensical](https://zensical.org)**. Tag any code block with a bundle ID and filename and a download button is injected automatically. Works with single files (direct download) or multiple files (ZIP archive).
21
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.
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. _(MkDocs/Material — see [Using with Zensical](https://mkdocs-zip-bundle-plugin.daemonless.io/configuration/#using-with-zensical) for the Zensical caveat.)_
23
+
24
+ **[Live demo → mkdocs-zip-bundle-plugin.daemonless.io](https://mkdocs-zip-bundle-plugin.daemonless.io)**
23
25
 
24
26
  ## Features
25
27
 
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mkdocs-zip-bundle-plugin"
7
- version = "0.1.1"
8
- description = "A MkDocs plugin to bundle specific code blocks into a downloadable ZIP or raw file."
7
+ version = "0.3.0"
8
+ description = "Bundle code blocks into downloadable ZIP or raw files — for MkDocs/Material and Zensical."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Daemonless" }]
11
- license = { text = "MIT" }
11
+ license = "MIT"
12
12
  requires-python = ">=3.8"
13
- keywords = ["mkdocs", "zip", "bundle", "placeholders", "interactive"]
13
+ keywords = ["mkdocs", "material", "zensical", "zip", "bundle", "placeholders", "interactive"]
14
14
  dependencies = [
15
15
  "mkdocs>=1.4.0",
16
16
  "beautifulsoup4>=4.11.0"
@@ -24,5 +24,8 @@ Repository = "https://github.com/daemonless/mkdocs-zip-bundle-plugin"
24
24
  [project.entry-points."mkdocs.plugins"]
25
25
  zip-bundle = "mkdocs_zip_bundle.plugin:ZipBundlePlugin"
26
26
 
27
+ [tool.setuptools.packages.find]
28
+ include = ["mkdocs_zip_bundle*"]
29
+
27
30
  [tool.setuptools.package-data]
28
31
  mkdocs_zip_bundle = ["assets/*"]
@@ -34,7 +34,20 @@ def test_sanitize_filename(plugin):
34
34
  assert plugin._sanitize_filename('some/../../path.txt') == 'some/path.txt'
35
35
  assert plugin._sanitize_filename('win\\path.txt') == 'win/path.txt'
36
36
  assert plugin._sanitize_filename('subdir/file.txt') == 'subdir/file.txt'
37
- assert plugin._sanitize_filename('//double//slash//') == 'double/slash/'
37
+ assert plugin._sanitize_filename('//double//slash//') == 'double/slash'
38
+ # Traversal bypass attempts
39
+ assert plugin._sanitize_filename('....//test.txt') == 'test.txt'
40
+ assert plugin._sanitize_filename('....//....//etc/passwd') == 'etc/passwd'
41
+ assert plugin._sanitize_filename('a/b/../../../c') == 'a/b/c'
42
+ assert plugin._sanitize_filename('C:\\Windows\\System32\\cmd.exe') == 'Windows/System32/cmd.exe'
43
+ assert plugin._sanitize_filename('foo/./bar') == 'foo/bar'
44
+ assert plugin._sanitize_filename('\0secret.txt') == 'secret.txt'
45
+ # Empty and dot-only inputs
46
+ assert plugin._sanitize_filename(' ') == ''
47
+ assert plugin._sanitize_filename('') == ''
48
+ assert plugin._sanitize_filename('..') == ''
49
+ assert plugin._sanitize_filename('...') == ''
50
+ assert plugin._sanitize_filename(None) == ''
38
51
 
39
52
 
40
53
  # --- _create_button ---
@@ -50,6 +63,16 @@ def test_create_button_single(plugin):
50
63
  assert 'onclick' not in button.attrs
51
64
 
52
65
 
66
+ def test_create_button_single_empty_filename(plugin):
67
+ """Empty or traversal-only filename should fall back safely to 'Download file'."""
68
+ soup = BeautifulSoup('', 'html.parser')
69
+ elements = [make_element(soup, {'data-zip-filename': ''})]
70
+ container = plugin._create_button(soup, 'my-bundle', elements)
71
+
72
+ button = container.find('button')
73
+ assert button.string == 'Download file'
74
+
75
+
53
76
  def test_create_button_single_subdir(plugin):
54
77
  soup = BeautifulSoup('', 'html.parser')
55
78
  elements = [make_element(soup, {'data-zip-filename': 'configs/compose.yaml'})]
@@ -1,67 +0,0 @@
1
- document.addEventListener('click', function(e) {
2
- const btn = e.target.closest('.zip-bundle-btn[data-bundle-id]');
3
- if (btn) {
4
- downloadZipBundle(btn.getAttribute('data-bundle-id'));
5
- }
6
- });
7
-
8
- async function downloadZipBundle(bundleId) {
9
- const elements = document.querySelectorAll(`[data-zip-bundle="${bundleId}"]`);
10
-
11
- if (elements.length === 0) {
12
- console.warn(`No elements found for bundle ID: ${bundleId}`);
13
- return;
14
- }
15
-
16
- const forceZip = Array.from(elements).some(el => el.getAttribute('data-zip-force') === 'true');
17
-
18
- // If it's only one file AND we aren't forcing a ZIP, download it directly
19
- if (elements.length === 1 && !forceZip) {
20
- const el = elements[0];
21
- const filename = el.getAttribute('data-zip-filename') || 'file';
22
- const codeEl = el.querySelector('code') || el;
23
- const content = codeEl.innerText;
24
-
25
- // Ensure UTF-8 encoding
26
- const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
27
- const url = URL.createObjectURL(blob);
28
- triggerDownload(url, filename);
29
- return;
30
- }
31
-
32
- // Multiple files (or forced single ZIP)
33
- if (typeof JSZip === 'undefined') {
34
- console.error('JSZip is not loaded. Please include it in your mkdocs.yml extra_javascript or enable it in the plugin config.');
35
- alert('Error: ZIP library not loaded.');
36
- return;
37
- }
38
-
39
- const zip = new JSZip();
40
- elements.forEach(el => {
41
- const filename = el.getAttribute('data-zip-filename') || 'unnamed-file';
42
- const codeEl = el.querySelector('code') || el;
43
- const content = codeEl.innerText;
44
-
45
- // Skip empty files if they have no content
46
- if (content.trim().length === 0) {
47
- console.warn(`Skipping empty file: ${filename}`);
48
- return;
49
- }
50
-
51
- zip.file(filename, content);
52
- });
53
-
54
- const blob = await zip.generateAsync({type: "blob"});
55
- const url = URL.createObjectURL(blob);
56
- triggerDownload(url, `${bundleId}.zip`);
57
- }
58
-
59
- function triggerDownload(url, filename) {
60
- const a = document.createElement('a');
61
- a.href = url;
62
- a.download = filename;
63
- document.body.appendChild(a);
64
- a.click();
65
- document.body.removeChild(a);
66
- URL.revokeObjectURL(url);
67
- }