diff --git a/.gitignore b/.gitignore
index 0efabcf..f7e515a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ app/static/cache/*
app/cypress/screenshots/*
.ruff_cache/
app/node_modules/
+app/static/vendor/
hadolint-results.sarif
build/
*.egg-info/
diff --git a/Dockerfile b/Dockerfile
index 2c6aee4..3b47662 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,6 +4,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
FLASK_HOST=0.0.0.0
+# hadolint ignore=DL3008
+RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm && rm -rf /var/lib/apt/lists/*
+
WORKDIR /tmp/build
COPY pyproject.toml README.md main.py ./
@@ -12,5 +15,6 @@ RUN python -m pip install --no-cache-dir .
WORKDIR /app
COPY app/ .
+RUN npm install --prefix /app
CMD ["python", "app.py"]
diff --git a/app/package.json b/app/package.json
index ab301ee..a41b3de 100644
--- a/app/package.json
+++ b/app/package.json
@@ -1,5 +1,16 @@
{
+ "dependencies": {
+ "@fortawesome/fontawesome-free": "^6.7.2",
+ "bootstrap": "5.2.2",
+ "bootstrap-icons": "1.9.1",
+ "jquery": "3.6.0",
+ "marked": "^4.3.0"
+ },
"devDependencies": {
"cypress": "^14.5.1"
+ },
+ "scripts": {
+ "build": "node scripts/copy-vendor.js",
+ "postinstall": "node scripts/copy-vendor.js"
}
}
diff --git a/app/scripts/copy-vendor.js b/app/scripts/copy-vendor.js
new file mode 100644
index 0000000..dcf516d
--- /dev/null
+++ b/app/scripts/copy-vendor.js
@@ -0,0 +1,71 @@
+'use strict';
+/**
+ * Copies third-party browser assets from node_modules into static/vendor/
+ * so Flask can serve them without any CDN dependency.
+ * Runs automatically via the "postinstall" npm hook.
+ */
+const fs = require('fs');
+const path = require('path');
+
+const NM = path.join(__dirname, '..', 'node_modules');
+const VENDOR = path.join(__dirname, '..', 'static', 'vendor');
+
+function copyFile(src, dest) {
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.copyFileSync(src, dest);
+}
+
+function copyDir(src, dest) {
+ fs.mkdirSync(dest, { recursive: true });
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
+ const s = path.join(src, entry.name);
+ const d = path.join(dest, entry.name);
+ entry.isDirectory() ? copyDir(s, d) : fs.copyFileSync(s, d);
+ }
+}
+
+// Bootstrap CSS + JS bundle
+copyFile(
+ path.join(NM, 'bootstrap', 'dist', 'css', 'bootstrap.min.css'),
+ path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.min.css')
+);
+copyFile(
+ path.join(NM, 'bootstrap', 'dist', 'js', 'bootstrap.bundle.min.js'),
+ path.join(VENDOR, 'bootstrap', 'js', 'bootstrap.bundle.min.js')
+);
+
+// Bootstrap Icons CSS + embedded fonts
+copyFile(
+ path.join(NM, 'bootstrap-icons', 'font', 'bootstrap-icons.css'),
+ path.join(VENDOR, 'bootstrap-icons', 'font', 'bootstrap-icons.css')
+);
+copyDir(
+ path.join(NM, 'bootstrap-icons', 'font', 'fonts'),
+ path.join(VENDOR, 'bootstrap-icons', 'font', 'fonts')
+);
+
+// Font Awesome Free CSS + webfonts
+copyFile(
+ path.join(NM, '@fortawesome', 'fontawesome-free', 'css', 'all.min.css'),
+ path.join(VENDOR, 'fontawesome', 'css', 'all.min.css')
+);
+copyDir(
+ path.join(NM, '@fortawesome', 'fontawesome-free', 'webfonts'),
+ path.join(VENDOR, 'fontawesome', 'webfonts')
+);
+
+// marked – browser UMD build (path varies by version)
+const markedCandidates = [
+ path.join(NM, 'marked', 'marked.min.js'), // v4.x
+ path.join(NM, 'marked', 'lib', 'marked.umd.min.js'), // v5.x
+ path.join(NM, 'marked', 'dist', 'marked.min.js'), // v9+
+];
+const markedSrc = markedCandidates.find(p => fs.existsSync(p));
+if (!markedSrc) throw new Error('marked: no browser UMD build found in node_modules');
+copyFile(markedSrc, path.join(VENDOR, 'marked', 'marked.min.js'));
+
+// jQuery
+copyFile(
+ path.join(NM, 'jquery', 'dist', 'jquery.min.js'),
+ path.join(VENDOR, 'jquery', 'jquery.min.js')
+);
diff --git a/app/templates/moduls/base.html.j2 b/app/templates/moduls/base.html.j2
index 8a57fc5..fdf00e4 100644
--- a/app/templates/moduls/base.html.j2
+++ b/app/templates/moduls/base.html.j2
@@ -9,22 +9,19 @@
href="{% if platform.favicon.cache %}{{ url_for('static', filename=platform.favicon.cache) }}{% endif %}"
>
-
+
-
+
-
+
-
+
-
+
-
+