Skip to content

docs: add cross-site announcement banner#9326

Merged
jdx merged 6 commits intomainfrom
feat/site-banner
Apr 23, 2026
Merged

docs: add cross-site announcement banner#9326
jdx merged 6 commits intomainfrom
feat/site-banner

Conversation

@jdx
Copy link
Copy Markdown
Owner

@jdx jdx commented Apr 23, 2026

Summary

  • Adds docs/.vitepress/theme/banner.ts + banner.css \u2014 fetches banner config from https://jdx.dev/banner.json and renders a dismissible announcement bar at the top of the docs
  • Wires it into the existing enhanceApp hook in theme/index.ts
  • Link scheme is validated to http:/https: so a compromised upstream can't inject a javascript: URL
  • Dismissals persist per banner id in localStorage; bumping the id in the source JSON re-shows it to everyone

Used to announce en.dev, and any future cross-site announcements.

Test plan

  • Run docs dev server, confirm banner appears at top of page
  • Click the \u00d7 \u2014 banner disappears and stays dismissed across reloads
  • Clear localStorage 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 per id in localStorage, and updates --vp-layout-top-height so the fixed header doesn’t overlap content. It is initialized from the theme enhanceApp hook.

Reviewed by Cursor Bugbot for commit 29102e2. Bugbot is set up for automated code reviews on this repo. Configure here.

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]>
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/.vitepress/theme/banner.css Outdated
@@ -0,0 +1,37 @@
.jdx-banner {
position: relative;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
position: relative;
position: fixed;
top: 0;
left: 0;
right: 0;

Comment on lines +14 to +15
export function initBanner(): void {
if (typeof window === "undefined") return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;

Comment on lines +58 to +72
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`,
);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 23, 2026

Greptile Summary

Adds a dismissible cross-site announcement banner to the VitePress docs theme. On client load, initBanner() fetches https://jdx.dev/banner.json, validates the optional link URL against javascript: injection, renders the banner via DOM APIs (safe from XSS), and persists dismissals by banner id in localStorage.

Confidence Score: 5/5

Safe 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

Filename Overview
docs/.vitepress/theme/banner.ts New module: fetches remote banner JSON, validates link URLs against javascript: injection, renders a dismissible DOM element, and adjusts --vp-layout-top-height. Missing noreferrer on external link; no duplicate-element guard before prepend.
docs/.vitepress/theme/banner.css New CSS: fixed-position top banner with dismiss button, brand color background, responsive font/padding at 640 px breakpoint. Clean and self-contained.
docs/.vitepress/theme/index.ts Wires initBanner() into enhanceApp; the call site is synchronous at app bootstrap rather than deferred to onMounted like the existing star-count DOM work.

Sequence Diagram

sequenceDiagram
    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
Loading

Fix All in Claude Code

Reviews (6): Last reviewed commit: "docs: drop cache: no-cache from banner f..." | Re-trigger Greptile

Comment thread docs/.vitepress/theme/banner.ts Outdated
Comment thread docs/.vitepress/theme/banner.ts Outdated
Comment on lines 14 to +16
enhanceApp({ app }) {
enhanceAppWithTabs(app);
initBanner();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Fix in Claude Code

Comment thread docs/.vitepress/theme/banner.css
jdx and others added 2 commits April 23, 2026 13:29
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]>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 07edbff. Configure here.

jdx and others added 2 commits April 23, 2026 13:53
- 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]>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 23, 2026

Hyperfine Performance

mise x -- echo

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]>
@jdx jdx merged commit f6c19db into main Apr 23, 2026
35 checks passed
@jdx jdx deleted the feat/site-banner branch April 23, 2026 19:38
mise-en-dev added a commit that referenced this pull request Apr 24, 2026
### 🐛 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant