Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions docs/.vitepress/theme/EndevSponsors.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<template>
<section
v-if="sponsors.length"
aria-labelledby="endev-sponsors-title"
class="EndevSponsors"
>
<div class="EndevSponsorsInner">
<p id="endev-sponsors-title" class="EndevSponsorsTitle">
Company sponsors
</p>
<div class="EndevSponsorsLogos">
<a
v-for="sponsor in sponsors"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Unvalidated external URL used as href

sponsor.url is inserted directly into the anchor's href with only a truthiness check. A javascript:… value would pass that check and execute in the visitor's browser if the en.dev/sponsors.json feed is ever compromised or returns unexpected data. The existing EndevFooter.vue hardcodes its URL precisely to avoid this, so the same level of safety should apply here.

Suggested change
v-for="sponsor in sponsors"
:href="sanitizeUrl(sponsor.url)"

And add a helper in <script setup>:

function sanitizeUrl(url) {
  try {
    const { protocol } = new URL(url);
    return protocol === 'https:' || protocol === 'http:' ? url : '#';
  } catch {
    return '#';
  }
}

Fix in Claude Code

:key="sponsor.name"
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

Using sponsor.name as a key can be problematic if multiple sponsors share the same name. It is safer to use sponsor.url or a unique ID from the payload to ensure stable rendering and avoid potential key collisions.

          :key="sponsor.url"

:aria-label="sponsor.name"
class="EndevSponsorsLogo"
:href="sponsor.url"
rel="noopener noreferrer"
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

For links to sponsored content, search engines recommend using rel="sponsored". This helps in adhering to SEO best practices for paid or compensated links.

          rel="noopener noreferrer sponsored"

target="_blank"
>
<img :alt="sponsor.name" :src="sponsor.logo" />
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

Consider adding loading="lazy" and decoding="async" to the sponsor logo images. Since this component is located in the footer, deferring the loading of these images can improve the initial page load performance.

          <img :alt="sponsor.name" :src="sponsor.logo" loading="lazy" decoding="async" />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Missing width/height on sponsor <img> causes CLS

The existing EndevFooter.vue sets explicit width and height on its image to prevent layout shifts. These sponsor logos lack dimensions, so every page load that renders this block will produce a measurable Cumulative Layout Shift while the images load.

Suggested change
<img :alt="sponsor.name" :src="sponsor.logo" />
<img :alt="sponsor.name" :src="sponsor.logo" width="120" height="22" />

Fix in Claude Code

</a>
</div>
<a class="EndevSponsorsCta" href="https://en.dev/#contact">
Sponsor the work
</a>
</div>
</section>
</template>

<script setup>
import { onMounted, ref } from "vue";

const sponsors = ref([]);

onMounted(async () => {
try {
const res = await fetch("https://en.dev/sponsors.json", {
headers: { Accept: "application/json" },
});
if (!res.ok) return;

const payload = await res.json();
sponsors.value = (Array.isArray(payload.sponsors) ? payload.sponsors : [])
.filter((sponsor) =>
sponsor?.kind !== "infrastructure" &&
sponsor?.name &&
sponsor?.url &&
sponsor?.logo
);
} catch {
sponsors.value = [];
}
});
Comment on lines +31 to +54
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 project uses TypeScript in other theme files (e.g., index.ts). To maintain consistency and leverage type safety, consider using <script setup lang="ts"> and defining an interface for the sponsor data structure.

<script setup lang="ts">
import { onMounted, ref } from "vue";

interface Sponsor {
  name: string;
  url: string;
  logo: string;
  kind?: string;
}

const sponsors = ref<Sponsor[]>([]);

onMounted(async () => {
  try {
    const res = await fetch("https://en.dev/sponsors.json", {
      headers: { Accept: "application/json" },
    });
    if (!res.ok) return;

    const payload = await res.json();
    const rawSponsors = Array.isArray(payload.sponsors) ? payload.sponsors : [];
    sponsors.value = rawSponsors.filter((sponsor: any) =>
      sponsor?.kind !== "infrastructure" &&
      sponsor?.name &&
      sponsor?.url &&
      sponsor?.logo
    );
  } catch {
    sponsors.value = [];
  }
});

</script>

<style scoped>
.EndevSponsors {
border-top: 1px solid var(--vp-c-divider);
padding: 22px 24px;
}

.EndevSponsorsInner {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 12px 18px;
justify-content: center;
margin: 0 auto;
max-width: 960px;
}

.EndevSponsorsTitle {
color: var(--vp-c-text-2);
font-size: 13px;
font-weight: 600;
margin: 0;
text-transform: uppercase;
}

.EndevSponsorsLogos {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}

.EndevSponsorsLogo {
align-items: center;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
display: inline-flex;
height: 40px;
justify-content: center;
padding: 8px 12px;
transition: border-color 0.2s ease, background-color 0.2s ease;
}

.EndevSponsorsLogo:hover {
background: var(--vp-c-bg-soft);
border-color: var(--vp-c-brand-1);
}

.EndevSponsorsLogo img {
display: block;
max-height: 22px;
max-width: 120px;
}

.EndevSponsorsCta {
color: var(--vp-c-text-2);
font-size: 13px;
font-weight: 500;
text-decoration: none;
transition: color 0.2s ease;
}

.EndevSponsorsCta:hover {
color: var(--vp-c-brand-1);
}
</style>
3 changes: 2 additions & 1 deletion docs/.vitepress/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import DefaultTheme from 'vitepress/theme'
import { h, onMounted, onUnmounted } from 'vue'
import UsageHero from './UsageHero.vue'
import EndevFooter from './EndevFooter.vue'
import EndevSponsors from './EndevSponsors.vue'
import { initBanner } from './banner'
import { data as starsData } from '../stars.data'
import './custom.css'
Expand All @@ -11,7 +12,7 @@ export default {
Layout() {
return h(DefaultTheme.Layout, null, {
'home-hero-before': () => h(UsageHero),
'layout-bottom': () => h(EndevFooter)
'layout-bottom': () => [h(EndevSponsors), h(EndevFooter)]
})
},
enhanceApp() {
Expand Down
Loading