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.
- {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/PKG-INFO +8 -6
- {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/README.md +4 -2
- mkdocs_zip_bundle_plugin-0.3.0/mkdocs_zip_bundle/assets/zip-bundle.js +172 -0
- {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/plugin.py +18 -11
- {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/PKG-INFO +8 -6
- {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/pyproject.toml +7 -4
- {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/tests/test_plugin.py +24 -1
- mkdocs_zip_bundle_plugin-0.1.1/mkdocs_zip_bundle/assets/zip-bundle.js +0 -67
- {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/LICENSE +0 -0
- {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/__init__.py +0 -0
- {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/assets/jszip.min.js +0 -0
- {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/assets/zip-bundle.css +0 -0
- {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/SOURCES.txt +0 -0
- {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
- {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
- {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/requires.txt +0 -0
- {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
- {mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/setup.cfg +0 -0
- {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.
|
|
4
|
-
Summary:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
{mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/plugin.py
RENAMED
|
@@ -43,15 +43,22 @@ class ZipBundlePlugin(BasePlugin):
|
|
|
43
43
|
"""
|
|
44
44
|
Sanitize filename to prevent path traversal while allowing subdirectories.
|
|
45
45
|
"""
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# Remove
|
|
49
|
-
filename =
|
|
50
|
-
#
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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'
|
|
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.
|
|
4
|
-
Summary:
|
|
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
|
-
|
|
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.
|
|
8
|
-
description = "
|
|
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 =
|
|
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
|
-
}
|
|
File without changes
|
{mkdocs_zip_bundle_plugin-0.1.1 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|