Skip to content
Merged
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
7 changes: 7 additions & 0 deletions doc/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,16 @@ export default defineConfig({
],
},

// GitHub and Discord stay here so the mobile overlay still has them.
// theme/Community.vue is the desktop pair, with live counts and the
// hand-drawn icons from moq.dev; custom.css hides these two in the
// navbar so they do not duplicate.
socialLinks: [
{ icon: "github", link: "https://github.com/moq-dev/moq" },
{ icon: "discord", link: "https://discord.gg/FCYF3p99mr" },
{ icon: "x", link: "https://x.com/kixelated" },
{ icon: "bluesky", link: "https://bsky.app/profile/kixel.me" },
{ icon: "linkedin", link: "https://www.linkedin.com/in/luke-curley-635a457/" },
Comment thread
kixelated marked this conversation as resolved.
],

editLink: {
Expand Down
110 changes: 110 additions & 0 deletions doc/.vitepress/theme/Community.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<script setup>
/**
* GitHub stars and Discord members in the navbar, with the hand-drawn icons
* from moq.dev. Hidden below 768px; the stock socialLinks keep GitHub and
* Discord reachable in the mobile overlay.
*
* The counts are fetched in the browser because the site is static and only
* rebuilt on deploy. Both APIs allow anonymous CORS requests; GitHub's limit is
* 60/hour per IP, so the result is cached in localStorage for an hour rather
* than refetched on every page.
*/
import { onMounted, ref } from "vue";

const GITHUB = "https://api.github.com/repos/moq-dev/moq";
const DISCORD = "https://discord.com/api/v10/invites/FCYF3p99mr?with_counts=true";

const CACHE_KEY = "moq.stats";
const CACHE_TTL = 60 * 60 * 1000;

const stars = ref();
const chatters = ref();

function readCache() {
try {
const cached = JSON.parse(localStorage.getItem(CACHE_KEY));
if (cached && Date.now() - cached.at <= CACHE_TTL) return cached;
} catch {}
}

function writeCache(stats) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ ...stats, at: Date.now() }));
} catch {}
}

async function fetchNumber(url, key) {
try {
// Discord echoes the requesting origin in Access-Control-Allow-Origin but
// marks the response cacheable without `Vary: Origin`, so a response cached
// for moq.dev fails the CORS check on doc.moq.dev. Skip the HTTP cache;
// localStorage above is the cache.
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return;
const value = (await res.json())[key];
return typeof value === "number" ? value : undefined;
} catch {}
}

onMounted(async () => {
let stats = readCache();
if (!stats) {
const [s, c] = await Promise.all([
fetchNumber(GITHUB, "stargazers_count"),
fetchNumber(DISCORD, "approximate_member_count"),
]);
stats = { stars: s, chatters: c };
if (s !== undefined && c !== undefined) writeCache(stats);
}
stars.value = stats.stars;
chatters.value = stats.chatters;
});
</script>

<template>
<div class="moq-community">
<a href="https://github.com/moq-dev/moq" title="GitHub">
<img src="/emoji/github.svg" alt="GitHub" />
<span v-if="stars !== undefined">{{ stars.toLocaleString() }}</span>
</a>
<a href="https://discord.moq.dev" title="Discord">
<img src="/emoji/discord.svg" alt="Discord" />
<span v-if="chatters !== undefined">{{ chatters.toLocaleString() }}</span>
</a>
</div>
</template>

<style scoped>
.moq-community {
display: none;
}

@media (min-width: 768px) {
.moq-community {
display: flex;
align-items: center;
gap: 1rem;
margin-left: 1rem;
}
}

.moq-community a {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.85rem;
font-weight: 700;
color: var(--vp-c-brand-1);
text-decoration: none;
}

.moq-community img {
height: 1.75rem;
width: auto;
transition: transform 0.25s;
}

.moq-community a:hover img {
transform: rotate(-6deg);
}
</style>
8 changes: 8 additions & 0 deletions doc/.vitepress/theme/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@
border-bottom-color: #334155;
}

/* Community.vue is the GitHub/Discord pair in the desktop navbar. Keep the
stock entries so the mobile overlay still has those links, and hide the
duplicates here (VPNavBarExtra is also inside VPNavBar). */
.VPNavBar .VPSocialLink[href="https://github.com/moq-dev/moq"],
.VPNavBar .VPSocialLink[href="https://discord.gg/FCYF3p99mr"] {
display: none;
}

/* Sidebar styling */
.VPSidebar {
background-color: #0f172a;
Expand Down
5 changes: 4 additions & 1 deletion doc/.vitepress/theme/index.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { h } from "vue";
import DefaultTheme from "vitepress/theme";
import Banner from "./Banner.vue";
import Community from "./Community.vue";
import "./custom.css";

export default {
extends: DefaultTheme,
Layout() {
// Render the site-wide notice above every page via the layout-top slot.
// Render the site-wide notice above every page via the layout-top slot,
// and the GitHub/Discord counts at the end of the navbar.
return h(DefaultTheme.Layout, null, {
"layout-top": () => h(Banner),
"nav-bar-content-after": () => h(Community),
});
},
};
66 changes: 66 additions & 0 deletions doc/public/emoji/discord.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 50 additions & 0 deletions doc/public/emoji/github.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading