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

Public repositories

- overview - (this repo) + 🌟 docs - proxmox-userscripts + hardened-platform-as-code - homelab + 🌟 homelab - docs + overview + (this repo) - hardened-platform-as-code + proxmox-userscripts

Infrastructure & Automation

- 🔒 kickstarter-scripts + 🔒 ansible-configs - 🔒 network-scripts + ci-cd-pipelines Infrastructure & Automation 🔒 dns-configs - 🔒 ansible-configs + 🔒 .dotfiles - 🔒 terraform-configs + 🔒 k8s-configs - ci-cd-pipelines + 🔒 kickstarter-scripts - 🔒 k8s-configs + 🔒 network-scripts - 🔒 .dotfiles + 🔒 terraform-configs
@@ -578,6 +578,21 @@

Domain Checker

+ + Network Command Helper +

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, + ); + } + }); +});