Conversation
Fetches banner config from https://jdx.dev/banner.json and renders a dismissible top-of-page announcement. Link scheme is validated to http(s): so a compromised upstream can't inject javascript: URLs. Dismissals persist per banner id in localStorage. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
There was a problem hiding this comment.
Code Review
This pull request introduces a dynamic site announcement banner that fetches content from a remote endpoint and manages its display state via local storage. The implementation includes a new CSS stylesheet, a TypeScript module for fetching and rendering the banner, and integration into the VitePress theme. Feedback focuses on improving the user experience and robustness: specifically, changing the banner's positioning to fixed to avoid layout gaps during scrolling, ensuring the initialization function is idempotent to prevent redundant network requests, and utilizing a ResizeObserver to keep the layout offset synchronized with the banner's height during window resizing.
| @@ -0,0 +1,37 @@ | |||
| .jdx-banner { | |||
| position: relative; | |||
There was a problem hiding this comment.
Using position: relative for the banner while offsetting the fixed navigation bar via --vp-layout-top-height causes a visual gap at the top of the viewport when scrolling down. As the banner scrolls away, the navigation bar remains fixed at its offset position. Changing the banner to position: fixed ensures it stays at the top of the viewport along with the navigation bar, providing a consistent user experience.
| position: relative; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; |
| export function initBanner(): void { | ||
| if (typeof window === "undefined") return; |
There was a problem hiding this comment.
The initBanner function should be idempotent to prevent multiple fetches or redundant banner injections if the function is called multiple times (e.g., during development with HMR or if enhanceApp is triggered again).
let initialized = false;
export function initBanner(): void {
if (typeof window === "undefined" || initialized) return;
initialized = true;| 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`, | ||
| ); | ||
| }); |
There was a problem hiding this comment.
The banner's height can change if the window is resized (e.g., text wrapping on smaller screens). Using a ResizeObserver ensures the --vp-layout-top-height CSS variable stays in sync with the actual height of the banner. This prevents layout issues where the navigation bar might overlap the banner or leave a gap.
const observer = new ResizeObserver(() => {
document.documentElement.style.setProperty(
"--vp-layout-top-height",
`${el.offsetHeight}px`,
);
});
btn.addEventListener("click", () => {
localStorage.setItem(STORAGE_KEY, b.id);
observer.disconnect();
el.remove();
document.documentElement.style.removeProperty("--vp-layout-top-height");
});
el.appendChild(btn);
document.body.prepend(el);
observer.observe(el);
Greptile SummaryAdds a dismissible cross-site announcement banner to the VitePress docs theme. On client load, Confidence Score: 5/5Safe to merge; all remaining findings are minor P2 style suggestions. The only new finding is a missing noreferrer on the external link, which is a best-practice P2. The core logic (XSS prevention via textContent, javascript: URL guard, localStorage-based dismissal) is sound. Previously flagged P1/P2 concerns (duplicate-element guard, enhanceApp call site) are pre-existing discussion items, not new blockers. No files require special attention beyond the inline suggestions already noted. Important Files Changed
Sequence DiagramsequenceDiagram
participant VP as VitePress (enhanceApp)
participant B as banner.ts
participant API as jdx.dev/banner.json
participant LS as localStorage
participant DOM as document.body
VP->>B: initBanner()
B->>API: fetch(ENDPOINT)
API-->>B: BannerData { id, enabled, message, link? }
B->>LS: getItem("jdx-banner-dismissed")
LS-->>B: dismissedId (or null)
alt not enabled or already dismissed
B-->>VP: return (no-op)
else show banner
B->>DOM: prepend .jdx-banner element
B->>DOM: requestAnimationFrame → set --vp-layout-top-height
DOM-->>B: user clicks ×
B->>LS: setItem("jdx-banner-dismissed", b.id)
B->>DOM: remove .jdx-banner + clear CSS var
end
Reviews (6): Last reviewed commit: "docs: drop cache: no-cache from banner f..." | Re-trigger Greptile |
| enhanceApp({ app }) { | ||
| enhanceAppWithTabs(app); | ||
| initBanner(); |
There was a problem hiding this comment.
initBanner() called in enhanceApp rather than onMounted
enhanceApp runs synchronously during Vue app bootstrap, before the component tree is mounted. All other imperative DOM work in this file (star count) is deferred to onMounted() for exactly this reason. While the async fetch means render() only runs after the DOM is ready in practice, the call site pattern is inconsistent and could break if an eager document.body access is ever added. Moving initBanner() inside the existing onMounted would align it with the established pattern.
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) <[email protected]>
- Bump z-index to 1001 so the banner sits above custom nav overrides (e.g. hk's .VPNav at z-index: 1000 !important). - Use inherit on the dismiss button so it matches text color across light/dark brand backgrounds. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 07edbff. Configure here.
| .then((r) => (r.ok ? (r.json() as Promise<BannerData>) : null)) | ||
| .then((b) => { | ||
| if (!b || !b.enabled) return; | ||
| if (localStorage.getItem(STORAGE_KEY) === b.id) return; |
There was a problem hiding this comment.
Strict equality breaks dismissal for non-string banner IDs
Medium Severity
The BannerData.id is typed as string, but since the data comes from an external JSON endpoint with only a type assertion (as Promise<BannerData>), a numeric id (e.g., 1) won't be caught at runtime. localStorage.setItem coerces the value to "1", but the subsequent localStorage.getItem(STORAGE_KEY) === b.id performs strict equality between "1" and 1, which is always false. This makes the banner undismissable whenever the upstream JSON uses a numeric id.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 07edbff. Configure here.
- Center message+link in the banner, position dismiss button absolutely at the right edge so it doesn't skew the centering. - Drop rel=noreferrer on the link so en.dev gets analytics attribution for traffic from the docs. Keep rel=noopener for security. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Left/right padding was asymmetric (1rem vs 2.75rem desktop; 0.75rem vs 2.5rem mobile), which shifted the "centered" text off from true viewport center. Match the sides so justify-content: center lines up with the viewport midpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Hyperfine Performance
|
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.4.19 x -- echo |
22.9 ± 0.6 | 21.3 | 24.6 | 1.00 |
mise x -- echo |
23.8 ± 0.5 | 22.2 | 27.1 | 1.04 ± 0.04 |
mise env
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.4.19 env |
23.2 ± 0.8 | 21.4 | 30.1 | 1.00 ± 0.05 |
mise env |
23.1 ± 0.8 | 21.3 | 31.8 | 1.00 |
mise hook-env
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.4.19 hook-env |
23.6 ± 0.6 | 21.9 | 27.9 | 1.00 |
mise hook-env |
24.4 ± 0.6 | 22.4 | 28.8 | 1.03 ± 0.04 |
mise ls
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.4.19 ls |
21.2 ± 0.8 | 19.1 | 30.5 | 1.00 |
mise ls |
21.8 ± 0.9 | 19.9 | 30.8 | 1.03 ± 0.06 |
xtasks/test/perf
| Command | mise-2026.4.19 | mise | Variance |
|---|---|---|---|
| install (cached) | 166ms | 169ms | -1% |
| ls (cached) | 77ms | 80ms | -3% |
| bin-paths (cached) | 82ms | 85ms | -3% |
| task-ls (cached) | 833ms | 826ms | +0% |
no-cache forces a conditional GET on every page load. The server sends Cache-Control: public, max-age=300, must-revalidate, so default browser caching already gives 5-min freshness, which is plenty for an announcement banner. Returning users with a dismissed banner also already short-circuit via localStorage before the fetch runs anyway. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
### 🐛 Bug Fixes - **(config)** resolve relative path: tool versions against config root by @jdx in [#9320](#9320) - **(lock)** resolve @latest and prune poisoned lockfile entries by @jdx in [#9321](#9321) - fix - be able to work with regex in attestation check by @monotek in [#9327](#9327) ### 🚜 Refactor - **(aqua)** bake aqua registry from merged yaml by @risu729 in [#9043](#9043) ### 📚 Documentation - add cross-site announcement banner by @jdx in [#9326](#9326) - keep banner height in sync via ResizeObserver by @jdx in [#9330](#9330) - respect banner expires field by @jdx in [#9334](#9334) ### 📦️ Dependency Updates - bump communique to 1.0.2 by @jdx in [#9313](#9313) - bump communique to 1.0.3 by @jdx in [#9332](#9332) - update actions/setup-node digest to 48b55a0 by @renovate[bot] in [#9339](#9339) - update ghcr.io/jdx/mise:alpine docker digest to a92efa5 by @renovate[bot] in [#9340](#9340) - update ghcr.io/jdx/mise:rpm docker digest to 5c24f69 by @renovate[bot] in [#9343](#9343) - update rust docker digest to e4f09e8 by @renovate[bot] in [#9345](#9345) - update rui314/setup-mold digest to 9c9c13b by @renovate[bot] in [#9344](#9344) - update ghcr.io/jdx/mise:deb docker digest to a3afe3e by @renovate[bot] in [#9342](#9342) - update ghcr.io/jdx/mise:copr docker digest to 4098d5a by @renovate[bot] in [#9341](#9341) - update taiki-e/install-action digest to 74e87cb by @renovate[bot] in [#9346](#9346) ### Chore - **(ci)** remove cargo-vendor install from ppa publish by @jdx in [#9312](#9312) - **(release)** publish snap to stable channel by @jdx in [#9318](#9318) - remove FUNDING.yml in favor of jdx/.github default by @jdx in [#9331](#9331) ## 📦 Aqua Registry Updated [aqua-registry](https://github.com/aquaproj/aqua-registry): [v4.492.0](https://github.com/aquaproj/aqua-registry/releases/tag/v4.492.0) -> [v4.498.0](https://github.com/aquaproj/aqua-registry/releases/tag/v4.498.0). Included aqua-registry releases: - [v4.493.0](https://github.com/aquaproj/aqua-registry/releases/tag/v4.493.0) - [v4.494.0](https://github.com/aquaproj/aqua-registry/releases/tag/v4.494.0) - [v4.494.1](https://github.com/aquaproj/aqua-registry/releases/tag/v4.494.1) - [v4.495.0](https://github.com/aquaproj/aqua-registry/releases/tag/v4.495.0) - [v4.496.0](https://github.com/aquaproj/aqua-registry/releases/tag/v4.496.0) - [v4.497.0](https://github.com/aquaproj/aqua-registry/releases/tag/v4.497.0) - [v4.498.0](https://github.com/aquaproj/aqua-registry/releases/tag/v4.498.0)


Summary
docs/.vitepress/theme/banner.ts+banner.css\u2014 fetches banner config fromhttps://jdx.dev/banner.jsonand renders a dismissible announcement bar at the top of the docsenhanceApphook intheme/index.tshttp:/https:so a compromised upstream can't inject ajavascript:URLlocalStorage; bumping the id in the source JSON re-shows it to everyoneUsed to announce en.dev, and any future cross-site announcements.
Test plan
jdx-banner-dismissed, reload \u2014 banner returns\U0001F916 Generated with Claude Code
Note
Medium Risk
Adds client-side docs UI that fetches and renders remote JSON at runtime; while URL schemes are validated, it introduces dependency on an external endpoint and uses localStorage/layout CSS variables.
Overview
Adds a dismissible site announcement banner to the VitePress docs theme, driven by a remote config fetched from
https://jdx.dev/banner.json.The banner renders a message and optional outbound link (restricted to
http/https), persists dismissals peridinlocalStorage, and updates--vp-layout-top-heightso the fixed header doesn’t overlap content. It is initialized from the themeenhanceApphook.Reviewed by Cursor Bugbot for commit 29102e2. Bugbot is set up for automated code reviews on this repo. Configure here.