Skip to content
58 changes: 58 additions & 0 deletions docs/.vitepress/theme/EndevFooter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<template>
<footer class="EndevFooter">
<span>MIT License</span>
<span aria-hidden="true">·</span>
<span>Copyright © {{ year }}</span>
<span aria-hidden="true">·</span>
<a class="EndevFooterLink" href="https://en.dev">
<img
alt=""
class="EndevFooterLogo"
height="28"
src="https://github.com/endevco.png?size=96"
width="28"
/>
<span>en.dev</span>
</a>
</footer>
</template>

<script setup>
const year = new Date().getFullYear();
</script>

<style scoped>
.EndevFooter {
align-items: center;
border-top: 1px solid var(--vp-c-divider);
color: var(--vp-c-text-2);
display: flex;
flex-wrap: wrap;
font-size: 14px;
gap: 8px;
justify-content: center;
line-height: 28px;
margin-top: auto;
padding: 22px 24px 26px;
text-align: center;
}
.EndevFooterLink {
align-items: center;
color: inherit;
display: inline-flex;
gap: 8px;
line-height: 28px;
text-decoration: none;
transition: color 0.2s ease;
}
.EndevFooterLink:hover {
color: var(--vp-c-brand-1);
}
.EndevFooterLogo {
border-radius: 8px;
display: inline-block;
height: 28px;
vertical-align: middle;
width: 28px;
}
</style>
9 changes: 4 additions & 5 deletions docs/.vitepress/theme/Layout.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
<template>
<Layout>
<!-- Custom header banner removed - using HomePage component instead -->

<!-- Removed nav-bar-title-after to avoid duplicate "hk" -->

<!-- Removed footer decoration to prevent visual issues -->
<template #layout-bottom>
<EndevFooter />
</template>
</Layout>
</template>

<script setup lang="ts">
import DefaultTheme from 'vitepress/theme'
import { useData } from 'vitepress'
import EndevFooter from './EndevFooter.vue'

const { Layout } = DefaultTheme
const { page } = useData()
Expand Down
45 changes: 45 additions & 0 deletions docs/.vitepress/theme/banner.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
.jdx-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1001;
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: center;
padding: 0.5rem 2.75rem;
background: var(--vp-c-brand-1, #3abff8);
color: #000;
font-size: 0.9rem;
line-height: 1.4;
text-align: center;
}
.jdx-banner a {
color: #000;
text-decoration: underline;
font-weight: 600;
}
.jdx-banner button {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: 0;
color: inherit;
font-size: 1.25rem;
cursor: pointer;
line-height: 1;
padding: 0 0.5rem;
opacity: 0.7;
}
.jdx-banner button:hover {
opacity: 1;
}
@media (max-width: 640px) {
.jdx-banner {
font-size: 0.85rem;
padding: 0.4rem 2.5rem;
}
}
82 changes: 82 additions & 0 deletions docs/.vitepress/theme/banner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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)
.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
Contributor

Choose a reason for hiding this comment

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

medium

Accessing localStorage can throw an error in some environments (e.g., if cookies/storage are blocked or in certain private browsing modes). It is safer to wrap this check in a try-catch block to ensure the banner logic doesn't crash the application initialization.

      try {
        if (localStorage.getItem(STORAGE_KEY) === b.id) return
      } catch (e) {
        // ignore storage errors
      }

render(b);
})
.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 msg = document.createElement("span");
msg.textContent = b.message;
el.appendChild(msg);

if (b.link && isHttpUrl(b.link)) {
const a = document.createElement("a");
a.href = b.link;
a.target = "_blank";
a.rel = "noopener";
a.textContent = b.linkText || "Learn more";

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

Similar to the retrieval, setting an item in localStorage can throw an error if storage is disabled or full. Wrapping this in a try-catch ensures the dismissal functionality fails gracefully without impacting the rest of the UI.

    try {
      localStorage.setItem(STORAGE_KEY, b.id)
    } catch (e) {
      // ignore storage errors
    }

el.appendChild(a);
}

const syncHeight = () => {
document.documentElement.style.setProperty(
"--vp-layout-top-height",
`${el.offsetHeight}px`,
);
};

const observer =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(syncHeight)
: null;

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);
observer?.disconnect();
el.remove();
document.documentElement.style.removeProperty("--vp-layout-top-height");
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unhandled localStorage.setItem exception blocks banner dismissal

Low Severity

The dismiss button's click handler calls localStorage.setItem as its first statement. If this throws (e.g., QuotaExceededError on a full storage, or storage disabled in certain browsing contexts), the remaining cleanup — observer?.disconnect(), el.remove(), and removeProperty("--vp-layout-top-height") — never executes. This leaves the banner permanently visible and undismissable for the session, since every click re-throws. Wrapping the setItem in a try-catch (or moving it after el.remove()) would let dismissal still work even when persistence fails.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 622e8db. Configure here.

el.appendChild(btn);

document.body.prepend(el);

requestAnimationFrame(syncHeight);
observer?.observe(el);
}
2 changes: 2 additions & 0 deletions docs/.vitepress/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ 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 {
extends: DefaultTheme,
Layout,
enhanceApp({ app, router, siteData }) {
app.component('HomePage', HomePage)
initBanner()
},
} satisfies Theme
Loading