From 165fcd87fe5e046fc201e6fa5940533ab55631fb Mon Sep 17 00:00:00 2001
From: Zepher Ashe
Date: Tue, 1 Sep 2026 22:33:54 +0100
Subject: [PATCH 1/3] Add repo shortcuts and multi-dropdown UX
Add a second repository dropdown with grouped links and private repo markers, plus responsive styling and stronger accessibility states. The JavaScript was refactored to manage multiple dropdown buttons via aria-controls, close the others when one opens, and keep Escape/click behavior consistent. Tests were updated to cover the multi-menu interactions and the expected repository grouping structure.
---
.gitignore | 2 +
public/assets/css/style.css | 79 +++++++++-
public/assets/js/script.js | 89 +++++------
public/index.html | 264 ++++++++++++++++++++++++++-------
tests/script.behaviour.test.js | 44 +++++-
tests/site.structure.test.js | 22 +++
6 files changed, 385 insertions(+), 115 deletions(-)
diff --git a/.gitignore b/.gitignore
index 8819666..8ba0a53 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
.DS_Store
assets/.DS_Store
+
+node_modules/
\ No newline at end of file
diff --git a/public/assets/css/style.css b/public/assets/css/style.css
index c981e4f..3081677 100644
--- a/public/assets/css/style.css
+++ b/public/assets/css/style.css
@@ -45,9 +45,19 @@ h1 {
}
/* Dropdown Menu */
+.header-actions {
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 16px;
+}
+
.dropdown {
position: relative;
display: inline-block;
+ text-align: left;
}
.dropdown-btn {
@@ -60,6 +70,17 @@ h1 {
font-size: 0.9em;
}
+.dropdown-btn:hover,
+.dropdown-btn:focus-visible {
+ background-color: #0056b3;
+}
+
+.dropdown-btn:focus-visible,
+.dropdown-content a:focus-visible {
+ outline: 3px solid #80bdff;
+ outline-offset: 2px;
+}
+
.dropdown-content {
display: none;
position: absolute;
@@ -68,7 +89,7 @@ h1 {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
border-radius: 4px;
padding: 10px 0;
- z-index: 1;
+ z-index: 10;
}
.dropdown-content a {
@@ -83,11 +104,67 @@ h1 {
background-color: #e8f1fa;
}
+.repository-menu {
+ right: 0;
+ width: min(380px, calc(100vw - 40px));
+ max-height: min(70vh, 620px);
+ overflow-y: auto;
+}
+
+.repository-group + .repository-group {
+ border-top: 1px solid #d8e2ec;
+ margin-top: 8px;
+ padding-top: 8px;
+}
+
+.repository-group h2 {
+ color: #4d5966;
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ padding: 4px 16px;
+ text-transform: uppercase;
+}
+
+.repository-menu a {
+ font-size: 0.9rem;
+ overflow-wrap: anywhere;
+}
+
+.repo-note {
+ color: #6c757d;
+ font-size: 0.85em;
+}
+
/* Show dropdown on click */
.show {
display: block;
}
+@media (max-width: 520px) {
+ .header-actions {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .dropdown,
+ .dropdown-btn {
+ width: 100%;
+ }
+
+ .dropdown-btn {
+ display: flex;
+ justify-content: space-between;
+ }
+
+ .dropdown-content,
+ .repository-menu {
+ left: 0;
+ right: auto;
+ width: 100%;
+ }
+}
+
/* Link Cards */
.links-grid {
display: grid;
diff --git a/public/assets/js/script.js b/public/assets/js/script.js
index 2cb053b..920d9c1 100644
--- a/public/assets/js/script.js
+++ b/public/assets/js/script.js
@@ -1,9 +1,8 @@
const DROPDOWN_VISIBLE_CLASS = "show";
const DROPDOWN_BUTTON_SELECTOR = ".dropdown-btn";
-const DROPDOWN_MENU_ID = "dropdownMenu";
const CURRENT_YEAR_ID = "currentYear";
-let isDropdownButtonInitialised = false;
+const initialisedDropdownButtons = new WeakSet();
let areGlobalDropdownListenersInitialised = false;
function setCurrentYear() {
@@ -16,65 +15,51 @@ function setCurrentYear() {
yearElement.textContent = String(new Date().getFullYear());
}
-function getDropdownMenu() {
- return document.getElementById(DROPDOWN_MENU_ID);
+function getDropdownButtons() {
+ return [...document.querySelectorAll(DROPDOWN_BUTTON_SELECTOR)];
}
-function getDropdownButton() {
- return document.querySelector(DROPDOWN_BUTTON_SELECTOR);
-}
-
-function setDropdownExpanded(isExpanded) {
- const dropdownButton = getDropdownButton();
-
- if (!dropdownButton) {
- return;
- }
+function getControlledMenu(dropdownButton) {
+ const menuId = dropdownButton?.getAttribute("aria-controls");
- dropdownButton.setAttribute("aria-expanded", String(isExpanded));
+ return menuId ? document.getElementById(menuId) : null;
}
-function toggleDropdown() {
- const dropdownMenu = getDropdownMenu();
+function closeDropdowns(exceptButton = null) {
+ for (const dropdownButton of getDropdownButtons()) {
+ if (dropdownButton === exceptButton) {
+ continue;
+ }
- if (!dropdownMenu) {
- return;
+ getControlledMenu(dropdownButton)?.classList.remove(
+ DROPDOWN_VISIBLE_CLASS,
+ );
+ dropdownButton.setAttribute("aria-expanded", "false");
}
-
- const isNowExpanded = dropdownMenu.classList.toggle(DROPDOWN_VISIBLE_CLASS);
- setDropdownExpanded(isNowExpanded);
}
-function closeDropdown() {
- const dropdownMenu = getDropdownMenu();
+function toggleDropdown(dropdownButton = getDropdownButtons()[0]) {
+ const dropdownMenu = getControlledMenu(dropdownButton);
- if (!dropdownMenu) {
+ if (!dropdownButton || !dropdownMenu) {
return;
}
- dropdownMenu.classList.remove(DROPDOWN_VISIBLE_CLASS);
- setDropdownExpanded(false);
+ const willOpen = !dropdownMenu.classList.contains(DROPDOWN_VISIBLE_CLASS);
+ closeDropdowns(dropdownButton);
+ dropdownMenu.classList.toggle(DROPDOWN_VISIBLE_CLASS, willOpen);
+ dropdownButton.setAttribute("aria-expanded", String(willOpen));
}
function handleDocumentClick(event) {
- const dropdownMenu = getDropdownMenu();
- const dropdownButton = getDropdownButton();
-
- if (!dropdownMenu || !dropdownButton) {
- return;
- }
-
- const clickedInsideMenu = dropdownMenu.contains(event.target);
- const clickedButton = dropdownButton.contains(event.target);
-
- if (!clickedInsideMenu && !clickedButton) {
- closeDropdown();
+ if (!event.target.closest?.(".dropdown")) {
+ closeDropdowns();
}
}
function handleDocumentKeydown(event) {
if (event.key === "Escape") {
- closeDropdown();
+ closeDropdowns();
}
}
@@ -89,24 +74,22 @@ function initialiseGlobalDropdownListeners() {
areGlobalDropdownListenersInitialised = true;
}
-function initialiseDropdownButton() {
- const dropdownButton = getDropdownButton();
+function initialiseDropdownButtons() {
+ for (const dropdownButton of getDropdownButtons()) {
+ if (initialisedDropdownButtons.has(dropdownButton)) {
+ continue;
+ }
- if (!dropdownButton || isDropdownButtonInitialised) {
- return;
+ dropdownButton.addEventListener("click", () => {
+ toggleDropdown(dropdownButton);
+ });
+ initialisedDropdownButtons.add(dropdownButton);
}
-
- dropdownButton.addEventListener("click", (event) => {
- event.stopPropagation();
- toggleDropdown();
- });
-
- isDropdownButtonInitialised = true;
}
function initialiseSite() {
setCurrentYear();
- initialiseDropdownButton();
+ initialiseDropdownButtons();
initialiseGlobalDropdownListeners();
}
@@ -122,5 +105,5 @@ if (document.readyState === "loading") {
window.setCurrentYear = setCurrentYear;
window.toggleDropdown = toggleDropdown;
-window.closeDropdown = closeDropdown;
+window.closeDropdown = closeDropdowns;
window.initialiseSite = initialiseSite;
diff --git a/public/index.html b/public/index.html
index 6b3d760..cb22b24 100644
--- a/public/index.html
+++ b/public/index.html
@@ -25,57 +25,214 @@ safesploitOrg Project Overview
safesploitOrg ecosystem.
-
+
@@ -389,9 +546,7 @@ Assets Repository
alt="PBS to Slurm Converter"
/>
PBS to Slurm Converter
-
- Convert PBS scripts to Slurm scripts
-
+ Convert PBS scripts to Slurm scripts
PBS to Slurm Converter
alt="OpenWRT Firewall Visualiser"
/>
OpenWRT Firewall Visualiser
-
- Visualise and manage OpenWRT firewall rules.
-
+ Visualise and manage OpenWRT firewall rules.
OpenWRT Firewall Visualiser
/>
Domain Checker
- Static-first domain checker with custom DNS, HTTPS probing, backend support.
+ Static-first domain checker with custom DNS,
+ HTTPS probing, backend support.
diff --git a/tests/script.behaviour.test.js b/tests/script.behaviour.test.js
index 81e1d65..395b86b 100644
--- a/tests/script.behaviour.test.js
+++ b/tests/script.behaviour.test.js
@@ -9,8 +9,14 @@ function createDom() {
-
-
+
+
@@ -56,7 +62,8 @@ describe("frontend JavaScript behaviour", () => {
const dom = createDom();
loadScript(dom);
- const dropdownMenu = dom.window.document.getElementById("dropdownMenu");
+ const dropdownMenu =
+ dom.window.document.getElementById("resourcesMenu");
expect(typeof dom.window.toggleDropdown).toBe("function");
expect(dropdownMenu.classList.contains("show")).toBe(false);
@@ -72,7 +79,8 @@ describe("frontend JavaScript behaviour", () => {
const dom = createDom();
loadScript(dom);
- const dropdownMenu = dom.window.document.getElementById("dropdownMenu");
+ const dropdownMenu =
+ dom.window.document.getElementById("resourcesMenu");
dropdownMenu.classList.add("show");
expect(dropdownMenu.classList.contains("show")).toBe(true);
@@ -87,12 +95,13 @@ describe("frontend JavaScript behaviour", () => {
expect(dropdownMenu.classList.contains("show")).toBe(false);
});
- test("clicking the dropdown button does not immediately close the dropdown", () => {
+ test("clicking an open dropdown button closes its menu", () => {
const dom = createDom();
loadScript(dom);
const button = dom.window.document.querySelector(".dropdown-btn");
- const dropdownMenu = dom.window.document.getElementById("dropdownMenu");
+ const dropdownMenu =
+ dom.window.document.getElementById("resourcesMenu");
dropdownMenu.classList.add("show");
@@ -105,4 +114,27 @@ describe("frontend JavaScript behaviour", () => {
expect(dropdownMenu.classList.contains("show")).toBe(false);
});
+
+ test("opening one dropdown closes the other", () => {
+ const dom = createDom();
+ loadScript(dom);
+
+ const [resourcesButton, repositoriesButton] = [
+ ...dom.window.document.querySelectorAll(".dropdown-btn"),
+ ];
+ const resourcesMenu =
+ dom.window.document.getElementById("resourcesMenu");
+ const repositoriesMenu =
+ dom.window.document.getElementById("repositoriesMenu");
+
+ resourcesButton.click();
+ expect(resourcesMenu.classList.contains("show")).toBe(true);
+ expect(resourcesButton.getAttribute("aria-expanded")).toBe("true");
+
+ repositoriesButton.click();
+ expect(resourcesMenu.classList.contains("show")).toBe(false);
+ expect(repositoriesMenu.classList.contains("show")).toBe(true);
+ expect(resourcesButton.getAttribute("aria-expanded")).toBe("false");
+ expect(repositoriesButton.getAttribute("aria-expanded")).toBe("true");
+ });
});
diff --git a/tests/site.structure.test.js b/tests/site.structure.test.js
index ce0c91f..57731e2 100644
--- a/tests/site.structure.test.js
+++ b/tests/site.structure.test.js
@@ -116,4 +116,26 @@ describe("static site structure", () => {
expect(fs.existsSync(resolveSiteAsset(src))).toBe(true);
}
});
+
+ test("repository shortcuts are grouped and identify private repositories", () => {
+ const document = getDocument();
+ const repositoryMenu = document.getElementById("repositoriesMenu");
+ const groupHeadings = [
+ ...repositoryMenu.querySelectorAll(".repository-group h2"),
+ ].map((heading) => heading.textContent.trim());
+ const repositoryLinks = [...repositoryMenu.querySelectorAll("a[href]")];
+
+ expect(groupHeadings).toEqual([
+ "Public repositories",
+ "Infrastructure & Automation",
+ "Web & Documentation",
+ "Labs (Demos)",
+ ]);
+ expect(repositoryLinks).toHaveLength(17);
+ expect(
+ repositoryLinks.filter((link) =>
+ link.textContent.trim().startsWith("🔒"),
+ ),
+ ).toHaveLength(12);
+ });
});
From 0339433ffa726c7f36d1d79f03b7eb64ec497ce6 Mon Sep 17 00:00:00 2001
From: Zepher Ashe
Date: Tue, 1 Sep 2026 22:41:44 +0100
Subject: [PATCH 2/3] Reorder repos and enforce alphabetical sorting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reorganized repository links in the public and infrastructure sections, adding emoji icons (🌟, 🔒) for better visual distinction. Updated tests to verify repositories are alphabetically sorted within each group and relaxed the private repository count validation to be more flexible.
---
.../assets/images/network-command-helper.png | Bin 0 -> 563 bytes
public/index.html | 63 +++++++++++-------
tests/site.structure.test.js | 32 +++++++--
3 files changed, 66 insertions(+), 29 deletions(-)
create mode 100644 public/assets/images/network-command-helper.png
diff --git a/public/assets/images/network-command-helper.png b/public/assets/images/network-command-helper.png
new file mode 100644
index 0000000000000000000000000000000000000000..51e53a76e008df27eea7a0a9d1141883d79f6f6d
GIT binary patch
literal 563
zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9GG!XV7ZFl&wk0|Vnz
zPZ!6KiaBrRZ1iPz6k*-2t*5expR3}~+#M>5ybGPA#V1UvdBV2g`i6Pd_j|U?Y&Mq*
zYih_{|G9kP@`Dfl{a}!|BQMX;{QEI;LP32E1JApD{)U5pKQbEZu(xAi`@Wn#<);h(
z+x&)`45G?fu(kb^p#
zfrDg%VFP0yn+CJQ0-g;yHV*v$`OBZbELe5_+)v|Ng`dwEW`CN`C;xsr+Z_APD;U)-
z{h#&g`wvYH5r*&k*ZunuEug{h>V9zj7hMhkhVSoJ+kX>d;b{1EKdAnR?uog~5B_bx
zDqqfB^qe94^Ks4}*(*$#9~@!OF=X)OIUvNmfkbBI7KR`8T8B}co(A+VPXJp4&xhrc
zx#M5FaQ-a~@qB8-Q3kddya7NzzSVMQMhdUv><1qFn_aafj{y>XU_RDBzB$GIUzril
zyZvq37kk!NOR;>9KYjb6$Ua%#rr+ydzRh5|-^X_F@ArRYpGsafesploitOrg Project Overview
@@ -578,6 +578,21 @@ Domain Checker
+
+
+ Network Command Helper
+
+ Assists with common network commands and configurations (Netgear and OpenWRT).
+
+
diff --git a/tests/site.structure.test.js b/tests/site.structure.test.js
index 57731e2..a98d479 100644
--- a/tests/site.structure.test.js
+++ b/tests/site.structure.test.js
@@ -132,10 +132,32 @@ describe("static site structure", () => {
"Labs (Demos)",
]);
expect(repositoryLinks).toHaveLength(17);
- expect(
- repositoryLinks.filter((link) =>
- link.textContent.trim().startsWith("🔒"),
- ),
- ).toHaveLength(12);
+ const privateRepositoryLinks = repositoryLinks.filter((link) =>
+ link.textContent.trim().startsWith("🔒"),
+ );
+
+ expect(privateRepositoryLinks.length).toBeGreaterThan(0);
+ });
+
+ test("repository shortcuts are alphabetical within each group", () => {
+ const document = getDocument();
+ const groups = [
+ ...document.querySelectorAll("#repositoriesMenu .repository-group"),
+ ];
+
+ for (const group of groups) {
+ const repositoryNames = [...group.querySelectorAll("a[href]")].map(
+ (link) =>
+ new URL(link.href).pathname
+ .split("/")
+ .pop()
+ .replace(/^\./, ""),
+ );
+ const sortedNames = [...repositoryNames].sort((first, second) =>
+ first.localeCompare(second, "en"),
+ );
+
+ expect(repositoryNames).toEqual(sortedNames);
+ }
});
});
From 94ccbf889c051ebc74be2b7334517e9a928ead9e Mon Sep 17 00:00:00 2001
From: Zepher Ashe
Date: Tue, 1 Sep 2026 22:57:50 +0100
Subject: [PATCH 3/3] Add Linkinator config and tests
Introduce linkinator.config.json with centralized skip patterns and update package.json's check:links script to remove inline --skip overrides so Linkinator reads the config. Add tests/tests/link-check-config.test.js to assert the script no longer contains --skip, the config file exists, and repository links marked private are skipped while public ones are checked. This centralizes link-exclusion rules and prevents divergence between package scripts and Linkinator behavior.
---
linkinator.config.json | 18 +++++++++++++
package.json | 2 +-
tests/link-check-config.test.js | 46 +++++++++++++++++++++++++++++++++
3 files changed, 65 insertions(+), 1 deletion(-)
create mode 100644 linkinator.config.json
create mode 100644 tests/link-check-config.test.js
diff --git a/linkinator.config.json b/linkinator.config.json
new file mode 100644
index 0000000..f5a0081
--- /dev/null
+++ b/linkinator.config.json
@@ -0,0 +1,18 @@
+{
+ "skip": [
+ "^mailto:",
+ "^#",
+ "^https://icons8\\.com/",
+ "^https://github\\.com/safesploitOrg/\\.dotfiles(?:[/?#]|$)",
+ "^https://github\\.com/safesploitOrg/ansible-configs(?:[/?#]|$)",
+ "^https://github\\.com/safesploitOrg/confluence-mirror(?:[/?#]|$)",
+ "^https://github\\.com/safesploitOrg/dns-configs(?:[/?#]|$)",
+ "^https://github\\.com/safesploitOrg/hugo-static(?:[/?#]|$)",
+ "^https://github\\.com/safesploitOrg/iam-homelab(?:[/?#]|$)",
+ "^https://github\\.com/safesploitOrg/k8s-configs(?:[/?#]|$)",
+ "^https://github\\.com/safesploitOrg/kickstarter-scripts(?:[/?#]|$)",
+ "^https://github\\.com/safesploitOrg/network-scripts(?:[/?#]|$)",
+ "^https://github\\.com/safesploitOrg/project1-ci-cd-test(?:[/?#]|$)",
+ "^https://github\\.com/safesploitOrg/terraform-configs(?:[/?#]|$)"
+ ]
+}
diff --git a/package.json b/package.json
index b1173dd..c6cfc1b 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"format:check": "prettier --check \"public/**/*.{html,css,js,json}\" \"tests/**/*.js\" \"*.md\"",
"lint:js": "eslint public/assets/js/**/*.js tests/**/*.js",
"lint:html": "html-validate public/index.html",
- "check:links": "linkinator public/index.html README.md --recurse --silent --skip \"^mailto:\" --skip \"^#\" --skip \"^https://icons8\\.com/\""
+ "check:links": "linkinator public/index.html README.md --recurse --silent"
},
"devDependencies": {
"@eslint/js": "^9.28.0",
diff --git a/tests/link-check-config.test.js b/tests/link-check-config.test.js
new file mode 100644
index 0000000..bb76ffd
--- /dev/null
+++ b/tests/link-check-config.test.js
@@ -0,0 +1,46 @@
+import fs from "node:fs";
+import path from "node:path";
+import { describe, expect, test } from "vitest";
+import { JSDOM } from "jsdom";
+import { readIndexHtml } from "./helpers/sitePaths.js";
+
+const PROJECT_ROOT = path.resolve(import.meta.dirname, "..");
+const LINKINATOR_CONFIG_PATH = path.join(
+ PROJECT_ROOT,
+ "linkinator.config.json",
+);
+const PACKAGE_JSON_PATH = path.join(PROJECT_ROOT, "package.json");
+
+function readJson(filePath) {
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
+}
+
+describe("link checker configuration", () => {
+ test("uses the Linkinator config instead of command-line skip overrides", () => {
+ const packageJson = readJson(PACKAGE_JSON_PATH);
+
+ expect(packageJson.scripts["check:links"]).not.toContain("--skip");
+ expect(fs.existsSync(LINKINATOR_CONFIG_PATH)).toBe(true);
+ });
+
+ test("skips private repository links but still checks public repositories", () => {
+ const config = readJson(LINKINATOR_CONFIG_PATH);
+ const skipPatterns = config.skip.map((pattern) => new RegExp(pattern));
+ const document = new JSDOM(readIndexHtml()).window.document;
+ const repositoryLinks = [
+ ...document.querySelectorAll("#repositoriesMenu a[href]"),
+ ];
+
+ for (const link of repositoryLinks) {
+ const href = link.getAttribute("href");
+ const isPrivate = link.textContent.trim().startsWith("🔒");
+ const isSkipped = skipPatterns.some((pattern) =>
+ pattern.test(href),
+ );
+
+ expect(isSkipped, `${href} has the wrong Linkinator status`).toBe(
+ isPrivate,
+ );
+ }
+ });
+});