mkdocs-zip-bundle-plugin 0.2.0__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.2.0/mkdocs_zip_bundle_plugin.egg-info → mkdocs_zip_bundle_plugin-0.3.0}/PKG-INFO +2 -2
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/assets/zip-bundle.js +37 -22
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/plugin.py +18 -11
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0/mkdocs_zip_bundle_plugin.egg-info}/PKG-INFO +2 -2
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/pyproject.toml +2 -2
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/tests/test_plugin.py +24 -1
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/LICENSE +0 -0
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/README.md +0 -0
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/__init__.py +0 -0
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/assets/jszip.min.js +0 -0
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle/assets/zip-bundle.css +0 -0
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/SOURCES.txt +0 -0
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/dependency_links.txt +0 -0
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/entry_points.txt +0 -0
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/requires.txt +0 -0
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/mkdocs_zip_bundle_plugin.egg-info/top_level.txt +0 -0
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/setup.cfg +0 -0
- {mkdocs_zip_bundle_plugin-0.2.0 → mkdocs_zip_bundle_plugin-0.3.0}/tests/test_integration.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mkdocs-zip-bundle-plugin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
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
|
|
@@ -5,12 +5,19 @@
|
|
|
5
5
|
// so on MkDocs/Material (server-injected) this is a harmless no-op.
|
|
6
6
|
|
|
7
7
|
function sanitizeFilename(filename) {
|
|
8
|
+
if (!filename) return '';
|
|
8
9
|
// Match plugin._sanitize_filename: prevent path traversal, allow subdirs.
|
|
9
|
-
filename = filename.replace(/\\/g, '/');
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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('/');
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
function titleCase(str) {
|
|
@@ -36,8 +43,9 @@ function buildButtonContainer(bundleId, elements) {
|
|
|
36
43
|
if (customLabel) {
|
|
37
44
|
btn.textContent = customLabel;
|
|
38
45
|
} else if (elements.length === 1 && !forceZip) {
|
|
39
|
-
const
|
|
40
|
-
const
|
|
46
|
+
const rawFilename = elements[0].getAttribute('data-zip-filename');
|
|
47
|
+
const filename = sanitizeFilename(rawFilename || '') || 'file';
|
|
48
|
+
const displayName = filename.split('/').pop() || 'file'; // basename
|
|
41
49
|
btn.textContent = `Download ${displayName}`;
|
|
42
50
|
} else {
|
|
43
51
|
const label = titleCase(bundleId.replace(/-/g, ' '));
|
|
@@ -88,6 +96,14 @@ if (window.document$ && typeof window.document$.subscribe === 'function') {
|
|
|
88
96
|
|
|
89
97
|
// ── Download handling ─────────────────────────────────────────────────
|
|
90
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
|
+
|
|
91
107
|
document.addEventListener('click', function(e) {
|
|
92
108
|
const btn = e.target.closest('.zip-bundle-btn[data-bundle-id]');
|
|
93
109
|
if (btn) {
|
|
@@ -96,7 +112,8 @@ document.addEventListener('click', function(e) {
|
|
|
96
112
|
});
|
|
97
113
|
|
|
98
114
|
async function downloadZipBundle(bundleId) {
|
|
99
|
-
const
|
|
115
|
+
const escapedId = (window.CSS && CSS.escape) ? CSS.escape(bundleId) : bundleId.replace(/"/g, '\\"');
|
|
116
|
+
const elements = document.querySelectorAll(`[data-zip-bundle="${escapedId}"]`);
|
|
100
117
|
|
|
101
118
|
if (elements.length === 0) {
|
|
102
119
|
console.warn(`No elements found for bundle ID: ${bundleId}`);
|
|
@@ -108,9 +125,9 @@ async function downloadZipBundle(bundleId) {
|
|
|
108
125
|
// If it's only one file AND we aren't forcing a ZIP, download it directly
|
|
109
126
|
if (elements.length === 1 && !forceZip) {
|
|
110
127
|
const el = elements[0];
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
const content =
|
|
128
|
+
const rawFilename = el.getAttribute('data-zip-filename');
|
|
129
|
+
const filename = sanitizeFilename(rawFilename || '') || 'file';
|
|
130
|
+
const content = extractCodeContent(el);
|
|
114
131
|
|
|
115
132
|
// Ensure UTF-8 encoding
|
|
116
133
|
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
|
@@ -128,20 +145,18 @@ async function downloadZipBundle(bundleId) {
|
|
|
128
145
|
|
|
129
146
|
const zip = new JSZip();
|
|
130
147
|
elements.forEach(el => {
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const content =
|
|
134
|
-
|
|
135
|
-
// Skip empty files if they have no content
|
|
136
|
-
if (content.trim().length === 0) {
|
|
137
|
-
console.warn(`Skipping empty file: ${filename}`);
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
148
|
+
const rawFilename = el.getAttribute('data-zip-filename');
|
|
149
|
+
const filename = sanitizeFilename(rawFilename || '') || 'unnamed-file';
|
|
150
|
+
const content = extractCodeContent(el);
|
|
140
151
|
|
|
141
|
-
zip.file(filename, content);
|
|
152
|
+
zip.file(filename, content, { date: new Date(0) });
|
|
142
153
|
});
|
|
143
154
|
|
|
144
|
-
const blob = await zip.generateAsync({
|
|
155
|
+
const blob = await zip.generateAsync({
|
|
156
|
+
type: "blob",
|
|
157
|
+
compression: "DEFLATE",
|
|
158
|
+
compressionOptions: { level: 6 }
|
|
159
|
+
});
|
|
145
160
|
const url = URL.createObjectURL(blob);
|
|
146
161
|
triggerDownload(url, `${bundleId}.zip`);
|
|
147
162
|
}
|
{mkdocs_zip_bundle_plugin-0.2.0 → 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,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mkdocs-zip-bundle-plugin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
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
|
|
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mkdocs-zip-bundle-plugin"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
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
13
|
keywords = ["mkdocs", "material", "zensical", "zip", "bundle", "placeholders", "interactive"]
|
|
14
14
|
dependencies = [
|
|
@@ -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'})]
|
|
File without changes
|
|
File without changes
|
{mkdocs_zip_bundle_plugin-0.2.0 → 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
|