From 7792edcb27f22e327f9861585025575d5ca264ed Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:15:00 -0500 Subject: [PATCH 01/10] docs: add cross-site announcement banner Fetches banner config from https://jdx.dev/banner.json and renders a dismissible top-of-page announcement. Dismissals persist per banner id in localStorage, so bumping the id re-shows it. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/.vitepress/theme/banner.css | 37 ++++++++++++++++++ docs/.vitepress/theme/banner.ts | 64 ++++++++++++++++++++++++++++++++ docs/.vitepress/theme/index.ts | 2 + 3 files changed, 103 insertions(+) create mode 100644 docs/.vitepress/theme/banner.css create mode 100644 docs/.vitepress/theme/banner.ts diff --git a/docs/.vitepress/theme/banner.css b/docs/.vitepress/theme/banner.css new file mode 100644 index 000000000..72b8e4fa4 --- /dev/null +++ b/docs/.vitepress/theme/banner.css @@ -0,0 +1,37 @@ +.jdx-banner { + position: relative; + z-index: 60; + display: flex; + gap: 0.75rem; + align-items: center; + padding: 0.5rem 1rem; + background: var(--vp-c-brand-1, #3451b2); + color: #fff; + font-size: 0.9rem; + line-height: 1.4; +} +.jdx-banner a { + color: #fff; + text-decoration: underline; + font-weight: 500; +} +.jdx-banner button { + margin-left: auto; + background: transparent; + border: 0; + color: #fff; + font-size: 1.25rem; + cursor: pointer; + line-height: 1; + padding: 0 0.25rem; + opacity: 0.85; +} +.jdx-banner button:hover { + opacity: 1; +} +@media (max-width: 640px) { + .jdx-banner { + font-size: 0.85rem; + padding: 0.4rem 0.75rem; + } +} diff --git a/docs/.vitepress/theme/banner.ts b/docs/.vitepress/theme/banner.ts new file mode 100644 index 000000000..c79adee17 --- /dev/null +++ b/docs/.vitepress/theme/banner.ts @@ -0,0 +1,64 @@ +import './banner.css' + +interface BannerData { + id: string + enabled: boolean + message: string + link?: string + linkText?: string +} + +const ENDPOINT = 'https://jdx.dev/banner.json' +const STORAGE_KEY = 'jdx-banner-dismissed' + +export function initBanner(): void { + if (typeof window === 'undefined') return + fetch(ENDPOINT, { cache: 'no-cache' }) + .then((r) => (r.ok ? (r.json() as Promise) : null)) + .then((b) => { + if (!b || !b.enabled) return + if (localStorage.getItem(STORAGE_KEY) === b.id) return + render(b) + }) + .catch(() => {}) +} + +function render(b: BannerData): void { + const el = document.createElement('div') + el.className = 'jdx-banner' + el.setAttribute('role', 'region') + el.setAttribute('aria-label', 'Site announcement') + + const msg = document.createElement('span') + msg.textContent = b.message + el.appendChild(msg) + + if (b.link) { + const a = document.createElement('a') + a.href = b.link + a.target = '_blank' + a.rel = 'noopener' + a.textContent = b.linkText || 'Learn more' + el.appendChild(a) + } + + const btn = document.createElement('button') + btn.type = 'button' + btn.setAttribute('aria-label', 'Dismiss') + btn.textContent = '\u00d7' + btn.addEventListener('click', () => { + localStorage.setItem(STORAGE_KEY, b.id) + el.remove() + document.documentElement.style.removeProperty('--vp-layout-top-height') + }) + el.appendChild(btn) + + document.body.prepend(el) + + requestAnimationFrame(() => { + document.documentElement.style.setProperty( + '--vp-layout-top-height', + `${el.offsetHeight}px`, + ) + }) +} diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index 7db6d0bf6..944a92d50 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -3,6 +3,7 @@ import type { Theme } from 'vitepress' import DefaultTheme from 'vitepress/theme-without-fonts' import Layout from './Layout.vue' import HomePage from './HomePage.vue' +import { initBanner } from './banner' import './style.css' export default { @@ -10,5 +11,6 @@ export default { Layout, enhanceApp({ app, router, siteData }) { app.component('HomePage', HomePage) + initBanner() }, } satisfies Theme From 8be55d1b3f27cc63db35e729400f82efde6f87d4 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:20:04 -0500 Subject: [PATCH 02/10] docs: validate banner link scheme to prevent javascript: URLs Only allow http(s): links from the upstream JSON. Defense-in-depth against a compromised jdx.dev injecting a javascript: URL that would execute arbitrary code in the docs origin on click. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/.vitepress/theme/banner.ts | 93 ++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 42 deletions(-) diff --git a/docs/.vitepress/theme/banner.ts b/docs/.vitepress/theme/banner.ts index c79adee17..393caf1cc 100644 --- a/docs/.vitepress/theme/banner.ts +++ b/docs/.vitepress/theme/banner.ts @@ -1,64 +1,73 @@ -import './banner.css' +import "./banner.css"; interface BannerData { - id: string - enabled: boolean - message: string - link?: string - linkText?: string + id: string; + enabled: boolean; + message: string; + link?: string; + linkText?: string; } -const ENDPOINT = 'https://jdx.dev/banner.json' -const STORAGE_KEY = 'jdx-banner-dismissed' +const ENDPOINT = "https://jdx.dev/banner.json"; +const STORAGE_KEY = "jdx-banner-dismissed"; export function initBanner(): void { - if (typeof window === 'undefined') return - fetch(ENDPOINT, { cache: 'no-cache' }) + if (typeof window === "undefined") return; + fetch(ENDPOINT, { cache: "no-cache" }) .then((r) => (r.ok ? (r.json() as Promise) : null)) .then((b) => { - if (!b || !b.enabled) return - if (localStorage.getItem(STORAGE_KEY) === b.id) return - render(b) + if (!b || !b.enabled) return; + if (localStorage.getItem(STORAGE_KEY) === b.id) return; + render(b); }) - .catch(() => {}) + .catch(() => {}); +} + +function isHttpUrl(value: string): boolean { + try { + const u = new URL(value, window.location.href); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } } function render(b: BannerData): void { - const el = document.createElement('div') - el.className = 'jdx-banner' - el.setAttribute('role', 'region') - el.setAttribute('aria-label', 'Site announcement') + const el = document.createElement("div"); + el.className = "jdx-banner"; + el.setAttribute("role", "region"); + el.setAttribute("aria-label", "Site announcement"); - const msg = document.createElement('span') - msg.textContent = b.message - el.appendChild(msg) + const msg = document.createElement("span"); + msg.textContent = b.message; + el.appendChild(msg); - if (b.link) { - const a = document.createElement('a') - a.href = b.link - a.target = '_blank' - a.rel = 'noopener' - a.textContent = b.linkText || 'Learn more' - el.appendChild(a) + if (b.link && isHttpUrl(b.link)) { + const a = document.createElement("a"); + a.href = b.link; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + a.textContent = b.linkText || "Learn more"; + el.appendChild(a); } - const btn = document.createElement('button') - btn.type = 'button' - btn.setAttribute('aria-label', 'Dismiss') - btn.textContent = '\u00d7' - btn.addEventListener('click', () => { - localStorage.setItem(STORAGE_KEY, b.id) - el.remove() - document.documentElement.style.removeProperty('--vp-layout-top-height') - }) - el.appendChild(btn) + const btn = document.createElement("button"); + btn.type = "button"; + btn.setAttribute("aria-label", "Dismiss"); + btn.textContent = "\u00d7"; + btn.addEventListener("click", () => { + localStorage.setItem(STORAGE_KEY, b.id); + el.remove(); + document.documentElement.style.removeProperty("--vp-layout-top-height"); + }); + el.appendChild(btn); - document.body.prepend(el) + document.body.prepend(el); requestAnimationFrame(() => { document.documentElement.style.setProperty( - '--vp-layout-top-height', + "--vp-layout-top-height", `${el.offsetHeight}px`, - ) - }) + ); + }); } From 10158292c2623c7e6c9161ae7b0809d17083c1c4 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:28:48 -0500 Subject: [PATCH 03/10] docs: fix banner double-offset by using position: fixed Banner was using position: relative which put it in document flow *and* VitePress applies --vp-layout-top-height offset, causing content to be pushed down twice. Switch to position: fixed so the banner is out of flow and --vp-layout-top-height alone handles the content offset (which is what VitePress's layout-top slot assumes). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/.vitepress/theme/banner.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/.vitepress/theme/banner.css b/docs/.vitepress/theme/banner.css index 72b8e4fa4..79f75f7cf 100644 --- a/docs/.vitepress/theme/banner.css +++ b/docs/.vitepress/theme/banner.css @@ -1,5 +1,8 @@ .jdx-banner { - position: relative; + position: fixed; + top: 0; + left: 0; + right: 0; z-index: 60; display: flex; gap: 0.75rem; From 9b7a45dc25161d2e39a1ef85d34928cdca6cec4a Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:46:14 -0500 Subject: [PATCH 04/10] docs: improve banner contrast and bump z-index above nav - Bump z-index to 1001 so the banner sits above custom nav overrides (e.g. hk's .VPNav at z-index: 1000 !important). - Use dark text on light brand backgrounds (coral/pink/gold/cyan) for readable contrast. Sites with dark brand backgrounds keep white text. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/.vitepress/theme/banner.css | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/.vitepress/theme/banner.css b/docs/.vitepress/theme/banner.css index 79f75f7cf..7c2dd0fdd 100644 --- a/docs/.vitepress/theme/banner.css +++ b/docs/.vitepress/theme/banner.css @@ -3,31 +3,31 @@ top: 0; left: 0; right: 0; - z-index: 60; + z-index: 1001; display: flex; gap: 0.75rem; align-items: center; padding: 0.5rem 1rem; background: var(--vp-c-brand-1, #3451b2); - color: #fff; + color: #000; font-size: 0.9rem; line-height: 1.4; } .jdx-banner a { - color: #fff; + color: #000; text-decoration: underline; - font-weight: 500; + font-weight: 600; } .jdx-banner button { margin-left: auto; background: transparent; border: 0; - color: #fff; + color: inherit; font-size: 1.25rem; cursor: pointer; line-height: 1; padding: 0 0.25rem; - opacity: 0.85; + opacity: 0.7; } .jdx-banner button:hover { opacity: 1; From 240c4d7833dd7d0b4662ed20c7d1d797a93fd205 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:49:44 -0500 Subject: [PATCH 05/10] docs: add en.dev footer Adds a small footer with license, copyright, and link back to en.dev, matching the footer used on the mise docs. Rendered via VitePress's layout-bottom slot. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/.vitepress/theme/EndevFooter.vue | 58 +++++++++++++++++++++++++++ docs/.vitepress/theme/Layout.vue | 9 ++--- 2 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 docs/.vitepress/theme/EndevFooter.vue diff --git a/docs/.vitepress/theme/EndevFooter.vue b/docs/.vitepress/theme/EndevFooter.vue new file mode 100644 index 000000000..72b124259 --- /dev/null +++ b/docs/.vitepress/theme/EndevFooter.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/docs/.vitepress/theme/Layout.vue b/docs/.vitepress/theme/Layout.vue index 1ded714f3..615c6207a 100644 --- a/docs/.vitepress/theme/Layout.vue +++ b/docs/.vitepress/theme/Layout.vue @@ -1,16 +1,15 @@