first commit

This commit is contained in:
2026-06-28 15:11:45 -04:00
commit 7e0e3a6289
109 changed files with 19187 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
public/* -linguist-detectable
+2
View File
@@ -0,0 +1,2 @@
# These are supported funding model platforms
custom: ['https://foggymtndrifter.com/#coffee'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+25
View File
@@ -0,0 +1,25 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
.vercel/
+4
View File
@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}
+7
View File
@@ -0,0 +1,7 @@
Copyright 2025 Stel Clementine
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+60
View File
@@ -0,0 +1,60 @@
# foggymtndrifter.com
Michael Kinder's personal website and blog.
This site features a quirky, developer-focused aesthetic with deep customization, local content management, and integrated features for reader interaction.
## ✨ Features
- **Terminal Aesthetic**: Heavily themed UI including a terminal-style interface.
- **Content Management**:
- **Blog**: Technical articles writing in MDX with Expressive Code syntax highlighting.
- **CV/Resume**: A dedicated "About" page formatted as a professional CV.
- **Legal Pages**: Privacy Policy and Terms of Use (queried from local markdown).
- **Interactive Elements**:
- **Cookie Consent**: Minimalist, non-intrusive popup.
- **Comments**: GitHub-powered comments via [Giscus](https://giscus.app).
- **Donations**: Custom "Buy Me a Coffee" modal integrated with Stripe for easy support.
- **Tech Stack**:
- Built with [Astro v5](https://astro.build)
- Styled with [Tailwind CSS v4](https://tailwindcss.com)
- Deployed on [Vercel](https://vercel.com)
## 🚀 Local Development
1. **Clone the repository**:
```bash
git clone https://github.com/FoggyMtnDrifter/website.git
cd website
```
2. **Install dependencies**:
```bash
npm install
```
3. **Start the dev server**:
```bash
npm run dev
```
4. **Build for production**:
```bash
npm run build
```
## 🛠️ Configuration
Site configuration is centralized in `src/site.config.ts`. This file controls:
- SEO metadata (Title, Description, Author)
- Navigation links
- Theme settings
- Social links & Giscus configuration
## 📄 License
This project is licensed under the [MIT License](LICENSE.txt).
## Acknowledgments
- Based on the [MultiTerm Astro](https://github.com/stelcodes/multiterm-astro) theme by [Stel](https://github.com/stelcodes).
+86
View File
@@ -0,0 +1,86 @@
// @ts-check
import { defineConfig } from 'astro/config'
import tailwindcss from '@tailwindcss/vite'
import sitemap from '@astrojs/sitemap'
import mdx from '@astrojs/mdx'
import svelte from '@astrojs/svelte'
import { rehypeHeadingIds } from '@astrojs/markdown-remark'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import expressiveCode from 'astro-expressive-code'
import siteConfig from './src/site.config'
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers'
import remarkDescription from './src/plugins/remark-description' /* Add description to frontmatter */
import remarkReadingTime from './src/plugins/remark-reading-time' /* Add reading time to frontmatter */
import rehypeTitleFigure from './src/plugins/rehype-title-figure' /* Wraps titles in figures */
import { remarkGithubCard } from './src/plugins/remark-github-card'
import { fromHtmlIsomorphic } from 'hast-util-from-html-isomorphic'
import rehypeExternalLinks from 'rehype-external-links'
import remarkDirective from 'remark-directive' /* Handle ::: directives as nodes */
import rehypeUnwrapImages from 'rehype-unwrap-images'
import { remarkAdmonitions } from './src/plugins/remark-admonitions' /* Add admonitions */
import remarkCharacterDialogue from './src/plugins/remark-character-dialogue' /* Custom plugin to handle character admonitions */
import remarkUnknownDirectives from './src/plugins/remark-unknown-directives' /* Custom plugin to handle unknown admonitions */
import remarkMath from 'remark-math' /* for latex math support */
import rehypeKatex from 'rehype-katex' /* again, for latex math support */
import remarkGemoji from './src/plugins/remark-gemoji' /* for shortcode emoji support */
import rehypePixelated from './src/plugins/rehype-pixelated' /* Custom plugin to handle pixelated images */
import vercel from '@astrojs/vercel'
// https://astro.build/config
export default defineConfig({
adapter: vercel(),
site: siteConfig.site,
trailingSlash: siteConfig.trailingSlashes ? 'always' : 'never',
prefetch: true,
markdown: {
remarkPlugins: [
[remarkDescription, { maxChars: 200 }],
remarkReadingTime,
remarkDirective,
remarkGithubCard,
remarkAdmonitions,
[remarkCharacterDialogue, { characters: siteConfig.characters }],
remarkUnknownDirectives,
remarkMath,
remarkGemoji,
],
rehypePlugins: [
[rehypeHeadingIds, { headingIdCompat: true }],
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
rehypeTitleFigure,
[
rehypeExternalLinks,
{
rel: ['noreferrer', 'noopener'],
target: '_blank',
},
],
rehypeUnwrapImages,
rehypePixelated,
rehypeKatex,
],
},
image: {
responsiveStyles: true,
},
vite: {
plugins: [tailwindcss()],
},
integrations: [
svelte(),
sitemap(),
expressiveCode({
themes: siteConfig.themes.include,
useDarkModeMediaQuery: false,
defaultProps: {
showLineNumbers: false,
wrap: false,
},
plugins: [pluginLineNumbers()],
}), // Must come after expressive-code integration
mdx(),
],
experimental: {
contentIntellisense: true,
},
})
+1
View File
@@ -0,0 +1 @@
The `theme-data.json` file is auto-generated and only used during theme development as a reference. It is not imported or required to build the site. Feel free to delete the whole `dev` directory if you like.
+1118
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
import { loadShikiTheme, type BundledShikiTheme } from 'astro-expressive-code'
import { bundledThemes } from 'shiki'
import { flattenThemeColors } from '~/utils'
// Use this function to export theme data for analysis
const exportThemeData = async () => {
const keyArrays = await Promise.all(
Object.keys(bundledThemes).map(async (theme) => {
const exTheme = await loadShikiTheme(theme as BundledShikiTheme)
const flatTheme = flattenThemeColors(exTheme)
return Object.keys(flatTheme)
}),
)
// Find intersection of all key arrays
const commonKeys = keyArrays.reduce((acc, keys) =>
acc.filter((key) => keys.includes(key)),
)
const allKeys = keyArrays.flat()
const keyCount = allKeys.reduce((acc: Record<string, number>, key) => {
acc[key] = (acc[key] || 0) + 1
return acc
}, {})
// Filter keys that appear in less than 10% of themes
// and sort them alphabetically
const sortedEntries = Object.entries(keyCount)
.filter(([_, count]) => count > Math.ceil(Object.keys(bundledThemes).length / 10))
.sort((a, b) => a[0].localeCompare(b[0]))
const jsonData = JSON.stringify(
{
allKeys: Object.fromEntries(sortedEntries),
commonKeys: commonKeys.sort(),
bundledThemes: Object.keys(bundledThemes).sort(),
},
null,
2,
)
const outputPath = './data/theme-data.json'
// Write to file
const fs = await import('fs/promises')
await fs.writeFile(outputPath, jsonData, 'utf-8')
console.log(`Theme data written to ${outputPath}`)
}
await exportThemeData()
+10796
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
{
"name": "multiterm-astro",
"description": "A terminal-inspired coding blog built with Astro.",
"type": "module",
"version": "1.0.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"postbuild": "pagefind --site dist",
"preview": "astro preview",
"astro": "astro",
"format": "prettier --write ."
},
"dependencies": {
"@astrojs/markdown-remark": "^6.3.6",
"@astrojs/mdx": "^4.3.4",
"@astrojs/node": "^9.5.2",
"@astrojs/rss": "^4.0.12",
"@astrojs/sitemap": "^3.5.1",
"@astrojs/svelte": "^7.2.5",
"@astrojs/ts-plugin": "^1.10.4",
"@astrojs/vercel": "^9.0.4",
"@expo-google-fonts/jetbrains-mono": "^0.4.0",
"@expressive-code/plugin-line-numbers": "^0.41.3",
"@fontsource-variable/jetbrains-mono": "^5.2.6",
"@fontsource/varela": "^5.2.8",
"@pagefind/default-ui": "^1.4.0",
"@resvg/resvg-js": "^2.6.2",
"@stripe/stripe-js": "^8.6.4",
"@tailwindcss/vite": "^4.1.13",
"@types/hast": "^3.0.4",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"astro": "^5.13.5",
"astro-expressive-code": "^0.41.3",
"color": "^5.0.0",
"cookie": "^1.1.1",
"date-fns": "^4.1.0",
"emoji-regex": "^10.5.0",
"gemoji": "^8.1.0",
"hast-util-from-html-isomorphic": "^2.0.0",
"hastscript": "^9.0.1",
"katex": "^0.16.22",
"markdown-it": "^14.1.0",
"mdast-util-directive": "^3.1.0",
"mdast-util-to-string": "^4.0.0",
"octokit": "^5.0.5",
"reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-external-links": "^3.0.0",
"rehype-katex": "^7.0.1",
"rehype-unwrap-images": "^1.0.0",
"remark-directive": "^4.0.0",
"remark-math": "^6.0.0",
"sanitize-html": "^2.17.0",
"satori": "^0.18.2",
"satori-html": "^0.3.2",
"stripe": "^20.2.0",
"svelte": "^5.49.0",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.2",
"unified": "^11.0.5"
},
"devDependencies": {
"@types/cookie": "^0.6.0",
"@types/markdown-it": "^14.1.2",
"@types/sanitize-html": "^2.16.0",
"pagefind": "^1.4.0",
"prettier": "3.6.2",
"prettier-plugin-astro": "0.14.1",
"sass-embedded": "^1.92.1"
}
}
+21
View File
@@ -0,0 +1,21 @@
// Typescript file would be better but it's currently experimental
const config = {
semi: false,
singleQuote: true,
trailingComma: 'all',
printWidth: 90,
tabWidth: 2,
useTabs: false,
plugins: ['prettier-plugin-astro'],
overrides: [
{
files: '*.astro',
options: {
parser: 'astro',
},
},
],
}
export default config
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+141
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+6
View File
@@ -0,0 +1,6 @@
<>
<h1 class="text-accent text-[1.6rem] pb-2.5 pl-1 mt-4 md:mt-7 font-semibold">
<slot />
</h1>
<div class="border-1 border-accent/30 rounded-xl h-1 w-full"></div>
</>
+469
View File
@@ -0,0 +1,469 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade, fly } from 'svelte/transition'
import { loadStripe } from '@stripe/stripe-js'
import type { Stripe, StripeElements } from '@stripe/stripe-js'
let dialog: HTMLDialogElement
let stripe: Stripe | null = null
let elements: StripeElements | null = null
let paymentElement: any = null /* Stripe Payment Element */
let isOpen = false
let step: 'selection' | 'payment' | 'success' = 'selection'
let amount = 5
let customAmount: number | null = null
let isLoading = false
let error: string | null = null
// Helper to check if a number is valid amount
$: finalAmount = customAmount && customAmount > 0 ? customAmount : amount
$: isCustom = customAmount && customAmount > 0
onMount(() => {
// Listen for triggers
const handleTrigger = (e: MouseEvent) => {
const target = e.target as HTMLElement
const trigger = target.closest('.bmc-trigger')
if (trigger) {
e.preventDefault()
openModal()
}
}
document.addEventListener('click', handleTrigger)
// Check hash immediately
if (window.location.hash === '#coffee') {
openModal()
}
// Listen for hash changes (e.g. if user clicks a link to #coffee while already on page)
const handleHashChange = () => {
if (window.location.hash === '#coffee') {
openModal()
}
}
window.addEventListener('hashchange', handleHashChange)
// Async initialization
;(async () => {
stripe = await loadStripe(import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY)
})()
// Cleanup
return () => {
document.removeEventListener('click', handleTrigger)
window.removeEventListener('hashchange', handleHashChange)
}
})
function openModal() {
if (!dialog) return
isOpen = true
step = 'selection'
error = null
isLoading = false
dialog.showModal()
}
function closeModal() {
if (!dialog) return
// Small delay to allow fade out if needed, but for native dialog close() handles it immediately usually.
// We will perform reset after closing.
dialog.close()
isOpen = false
setTimeout(() => {
resetState()
}, 300)
}
function resetState() {
step = 'selection'
error = null
isLoading = false
// destroy stripe elements if they exist
if (paymentElement) {
paymentElement.destroy()
paymentElement = null
elements = null
}
}
function handleOutsideClick(e: MouseEvent) {
const rect = dialog.getBoundingClientRect()
const isInDialog =
rect.top <= e.clientY &&
e.clientY <= rect.top + rect.height &&
rect.left <= e.clientX &&
e.clientX <= rect.left + rect.width
if (!isInDialog) {
closeModal()
}
}
async function startPayment() {
if (finalAmount < 5) {
alert('Please enter an amount of at least $5.00')
return
}
isLoading = true
error = null
try {
const response = await fetch('/api/payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: finalAmount,
isCustom: isCustom,
}),
})
const data = await response.json()
if (data.error) throw new Error(data.error)
const clientSecret = data.clientSecret
if (!stripe) throw new Error('Stripe failed to load')
const computedStyle = getComputedStyle(document.documentElement)
const getVar = (name: string) => computedStyle.getPropertyValue(name).trim()
elements = stripe.elements({
clientSecret,
fonts: [
{
cssSrc: 'https://fonts.googleapis.com/css2?family=Varela&display=swap',
},
],
appearance: {
theme: 'night',
variables: {
colorPrimary: getVar('--theme-accent'),
colorBackground: getVar('--theme-background'),
colorText: getVar('--theme-foreground'),
colorDanger: getVar('--theme-red'),
fontFamily: 'Varela, sans-serif',
borderRadius: '0.75rem',
},
rules: {
'.Input': {
backgroundColor: 'color-mix(in srgb, var(--colorPrimary) 10%, transparent)',
borderColor: 'transparent',
boxShadow: 'none',
},
'.Input:focus': {
borderColor: 'var(--colorPrimary)',
backgroundColor: 'color-mix(in srgb, var(--colorPrimary) 15%, transparent)',
boxShadow: 'none',
},
'.Tab': {
border: 'none',
boxShadow: 'none',
backgroundColor: 'color-mix(in srgb, var(--colorPrimary) 5%, transparent)',
},
'.Tab:hover': {
backgroundColor: 'color-mix(in srgb, var(--colorPrimary) 10%, transparent)',
},
'.Tab--selected': {
borderColor: 'var(--colorPrimary)',
backgroundColor: 'color-mix(in srgb, var(--colorPrimary) 15%, transparent)',
boxShadow: 'none',
},
'.Block': {
border: 'none',
boxShadow: 'none',
},
'.Label': {
fontWeight: '500',
color: 'var(--colorText)',
opacity: '0.8',
},
},
},
})
paymentElement = elements.create('payment')
// Mount happens in markup via action or just bind:this, but here we wait for step change
step = 'payment'
// Wait for DOM update
setTimeout(() => {
const container = document.getElementById('payment-element-container')
if (container && paymentElement) {
paymentElement.mount('#payment-element-container')
}
}, 0)
} catch (err) {
console.error(err)
error = (err as Error).message
} finally {
isLoading = false
}
}
async function confirmPayment() {
isLoading = true
error = null
if (!stripe || !elements) return
const { error: stripeError, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/?success=true`,
},
redirect: 'if_required',
})
if (stripeError) {
error = stripeError.message || 'Payment failed'
isLoading = false
} else if (paymentIntent && paymentIntent.status === 'succeeded') {
step = 'success'
isLoading = false
}
}
function handleRadioChange(val: number) {
amount = val
customAmount = null
}
function handleCustomInput(e: Event) {
const val = (e.target as HTMLInputElement).value
if (val) {
customAmount = parseFloat(val)
} else {
customAmount = null
}
}
</script>
<dialog
bind:this={dialog}
on:close={closeModal}
on:click={handleOutsideClick}
class="backdrop:bg-black/50 bg-background border-2 border-accent/20 rounded-xl p-0 w-[90%] max-w-md shadow-2xl transition-all duration-300 text-foreground m-auto"
>
<div class="relative overflow-hidden">
<!-- Header -->
{#if step === 'selection'}
<div
in:fly={{ y: -20, duration: 300 }}
out:fade={{ duration: 200 }}
class="bg-accent/10 text-center border-b border-accent/10 p-6"
>
<h2
class="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-accent to-accent/70"
>
Buy Me a Coffee
</h2>
<p class="text-sm text-foreground/70 mt-2">
Keep the terminal running and the caffeine flowing. Support my projects and
content.
</p>
</div>
{/if}
<button
on:click={closeModal}
class="absolute top-4 right-4 text-foreground/50 hover:text-foreground transition-colors cursor-pointer z-10 bg-transparent border-0 p-0"
aria-label="Close modal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<!-- Body -->
<div class="p-6">
{#if step === 'selection'}
<div
in:fly={{ x: -20, duration: 300, delay: 150 }}
out:fly={{ x: -20, duration: 200 }}
class="w-full"
>
<!-- Amount Selector -->
<div class="grid grid-cols-3 gap-3 mb-6">
{#each [5, 10, 20] as val}
<label class="cursor-pointer relative">
<input
type="radio"
name="amount"
value={val}
checked={amount === val && !customAmount}
on:change={() => handleRadioChange(val)}
class="peer sr-only"
/>
<div
class="h-12 flex items-center justify-center rounded-lg border-2 border-accent/20 peer-checked:border-accent peer-checked:bg-accent/10 hover:border-accent/50 transition-all font-bold"
>
${val}
</div>
</label>
{/each}
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2 text-foreground/80">
Custom Amount (USD)
</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-foreground/50"
>$</span
>
<input
type="number"
step="0.50"
min="5"
placeholder="Other amount"
value={customAmount || ''}
on:input={handleCustomInput}
class="w-full bg-background border-2 border-accent/20 rounded-lg py-2 pl-8 pr-4 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent transition-all"
/>
</div>
</div>
<button
on:click={startPayment}
disabled={isLoading}
class="w-full bg-accent hover:bg-accent/90 text-background font-bold py-3 px-6 rounded-lg transition-all transform active:scale-95 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
>
{#if isLoading}
<svg
class="animate-spin h-5 w-5 text-current"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
><circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle><path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path></svg
>
Loading...
{:else}
Enter Payment Details
{/if}
</button>
</div>
{:else if step === 'payment'}
<div
in:fly={{ x: 20, duration: 300, delay: 150 }}
out:fly={{ x: 20, duration: 200 }}
class="w-full"
>
<div class="text-center mb-6">
<p class="text-sm text-foreground/60">Your Contribution:</p>
<p class="text-3xl font-bold text-accent mt-1">
${finalAmount.toFixed(2)}
</p>
</div>
<div id="payment-element-container" class="mb-4 min-h-[100px]"></div>
{#if error}
<div class="text-sm text-red-500 text-center mb-4">
{error}
</div>
{/if}
<button
on:click={confirmPayment}
disabled={isLoading}
class="w-full bg-accent hover:bg-accent/90 text-background font-bold py-3 px-6 rounded-lg transition-all transform active:scale-95 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
>
{#if isLoading}
<svg
class="animate-spin h-5 w-5 text-current"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
><circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle><path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path></svg
>
Processing...
{:else}
Submit Donation
{/if}
</button>
</div>
{:else if step === 'success'}
<div in:fly={{ y: 20, duration: 300 }} class="w-full text-center py-8">
<div class="text-6xl mb-4">☕️</div>
<h3
class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-accent to-accent/70 mb-2"
>
Thank you for your support!
</h3>
<p class="text-foreground/80 mb-6 px-4">
Your contribution helps keep my projects and content alive. I truly appreciate
it!
</p>
<button
on:click={closeModal}
class="w-full bg-accent/10 hover:bg-accent/20 text-accent font-bold py-2 px-4 rounded-lg transition-colors border border-accent/20"
>
Close
</button>
</div>
{/if}
<p class="text-xs text-center text-foreground/40 mt-4">
Payments Secured by Stripe
</p>
</div>
</div>
</dialog>
<style>
dialog::backdrop {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
}
dialog[open] {
animation: zoom-in 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes zoom-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>
+486
View File
@@ -0,0 +1,486 @@
<script lang="ts">
import { onMount } from 'svelte'
export let slug: string
let comments: any[] = []
let user: any = null
let isLoading = true
let isSubmitting = false
let error = ''
let newComment = ''
let displayName = ''
let authMode: 'guest' | 'github' = 'guest'
// Captcha State
let num1 = Math.floor(Math.random() * 10) + 1
let num2 = Math.floor(Math.random() * 10) + 1
let captchaAnswer = ''
// Honeypot State
let honeyPot = ''
// Fetch User
async function checkAuth() {
try {
const res = await fetch('/api/auth/user')
const data = await res.json()
user = data.user
if (user) authMode = 'github'
} catch (e) {
console.error(e)
}
}
// Fetch Comments
async function fetchComments() {
isLoading = true
try {
const res = await fetch(`/api/comments/${slug}`)
const data = await res.json()
if (data.discussion) {
comments = data.discussion.comments.nodes
}
} catch (e) {
error = 'Failed to load comments'
} finally {
isLoading = false
}
}
onMount(async () => {
await Promise.all([checkAuth(), fetchComments()])
})
async function handleSubmit() {
if (!newComment.trim()) return
if (authMode === 'guest' && !displayName.trim()) {
alert('Please enter a display name')
return
}
// Basic client-side validation for captcha if guest
if (!user && authMode === 'guest') {
if (parseInt(captchaAnswer) !== num1 + num2) {
alert('Incorrect math answer. Please try again.')
// Reset
num1 = Math.floor(Math.random() * 10) + 1
num2 = Math.floor(Math.random() * 10) + 1
captchaAnswer = ''
return
}
}
isSubmitting = true
try {
const res = await fetch(`/api/comments/${slug}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: newComment,
displayName: authMode === 'guest' ? displayName : undefined,
website_honey: honeyPot,
captcha: !user ? { num1, num2, answer: captchaAnswer } : undefined,
}),
})
if (!res.ok) {
const txt = await res.text()
throw new Error(txt || 'Failed to post')
}
const addedComment = await res.json()
comments = [addedComment, ...comments] // Prepend new comment
newComment = ''
captchaAnswer = ''
// Reset Captcha
num1 = Math.floor(Math.random() * 10) + 1
num2 = Math.floor(Math.random() * 10) + 1
} catch (e: any) {
error = e.message
} finally {
isSubmitting = false
}
}
function signIn() {
// Save current path to redirect back
const redirect = window.location.pathname
window.location.href = `/api/auth/signin?redirect_to=${encodeURIComponent(redirect)}`
}
</script>
<div class="comments-section not-prose mt-12">
<!-- Comment Form (Collapsible) -->
<details
class="group mb-12 border border-accent/20 rounded-2xl bg-foreground/3 open:bg-foreground/5 transition-colors"
open={comments.length === 0}
>
<summary
class="flex items-center justify-between p-4 cursor-pointer list-none font-semibold text-heading1 select-none"
>
<span>Leave a comment</span>
<svg
class="w-5 h-5 text-accent transition-transform group-open:rotate-180"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</summary>
<div class="px-6 pb-6 pt-2 border-t border-accent/10">
{#if !user}
<div class="flex gap-3 mb-6">
<button
type="button"
class="flex-1 py-3 px-4 rounded-lg border-2 font-bold transition-all text-sm {authMode ===
'guest'
? 'border-accent bg-accent/10 text-accent'
: 'border-accent/20 hover:border-accent/50 text-foreground/70'}"
on:click={() => (authMode = 'guest')}
>
Post as Guest
</button>
<button
type="button"
class="flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg border-2 font-bold transition-all text-sm {authMode ===
'github'
? 'border-accent bg-accent/10 text-accent'
: 'border-accent/20 hover:border-accent/50 text-foreground/70'}"
on:click={() => signIn()}
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"
><path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/></svg
>
Sign in with GitHub
</button>
</div>
{/if}
{#if user}
<div
class="flex items-center gap-3 mb-6 p-4 bg-background border-2 border-accent/20 rounded-lg"
>
<img
src={user.avatar_url}
alt={user.login}
class="w-10 h-10 rounded-full ring-2 ring-accent/20"
/>
<div class="flex-1">
<div class="text-sm text-foreground/60">Signed in as</div>
<div class="font-bold text-heading1">{user.login}</div>
</div>
<a
href="/api/auth/signout"
class="text-xs font-bold px-3 py-1.5 rounded-lg border border-accent/20 text-foreground/60 hover:text-red-400 hover:border-red-400/50 transition-colors"
>Sign out</a
>
</div>
{/if}
<form on:submit|preventDefault={handleSubmit} class="space-y-5">
<!-- Honeypot -->
<input
type="text"
name="website_honey"
class="hidden"
bind:value={honeyPot}
tabindex="-1"
autocomplete="off"
/>
{#if authMode === 'guest' && !user}
<div>
<label
for="displayName"
class="block text-sm font-medium text-foreground/80 mb-2 ml-1"
>Display Name</label
>
<input
id="displayName"
type="text"
bind:value={displayName}
placeholder="Jane Doe"
class="w-full bg-background border-2 border-accent/20 rounded-lg py-2 px-4 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent transition-all text-foreground placeholder-foreground/30"
required
/>
</div>
{/if}
<div>
<label
for="comment"
class="block text-sm font-medium text-foreground/80 mb-2 ml-1">Comment</label
>
<div class="relative">
<textarea
id="comment"
bind:value={newComment}
rows="4"
placeholder={authMode === 'guest'
? 'Only simple markdown is supported.'
: 'Write with full markdown support...'}
class="w-full bg-background border-2 border-accent/20 rounded-lg py-2 px-4 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent transition-all text-foreground placeholder-foreground/30 resize-y min-h-[120px]"
required
></textarea>
<div class="absolute bottom-3 right-3 flex gap-2">
<a
href="https://www.markdownguide.org/cheat-sheet/"
target="_blank"
rel="noopener noreferrer"
class="hidden sm:inline-flex items-center text-[10px] text-foreground/30 hover:text-accent transition-colors"
>
<svg
class="w-3 h-3 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg
>
Markdown supported
</a>
</div>
</div>
</div>
<!-- Captcha for Guest -->
{#if authMode === 'guest' && !user}
<div>
<label
for="captcha"
class="block text-sm font-medium text-foreground/80 mb-2 ml-1"
>Human Check: What is {num1} + {num2}?</label
>
<input
id="captcha"
type="number"
bind:value={captchaAnswer}
placeholder="?"
class="w-32 bg-background border-2 border-accent/20 rounded-lg py-2 px-4 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent transition-all text-foreground placeholder-foreground/30"
required
/>
</div>
{/if}
<!-- Global button style applied here by using the 'button' tag without override classes except layout -->
<button
type="submit"
disabled={isSubmitting}
class="w-full bg-accent hover:bg-accent/90 text-background font-bold py-3 px-6 rounded-lg transition-all transform active:scale-95 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
>
{#if isSubmitting}
<svg
class="animate-spin h-5 w-5 text-current"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
><circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle><path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path></svg
>
Posting...
{:else}
Post Comment
{/if}
</button>
</form>
</div>
</details>
{#if isLoading}
<div class="flex items-center justify-center py-8 text-foreground/60">
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-accent"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Loading comments...
</div>
{:else if error}
<p class="text-red-500 bg-red-500/10 p-4 rounded-lg border border-red-500/20">
{error}
</p>
{:else}
<div class="space-y-8 mb-12">
{#each comments as comment}
<div class="comment-thread group">
<!-- Main Comment -->
<div class="flex gap-4">
<img
src={comment.author.avatarUrl}
alt={comment.author.login}
class="w-10 h-10 rounded-full border border-accent/20 flex-shrink-0"
/>
<div class="flex-1 min-w-0">
<div
class="bg-foreground/5 px-4 py-3 rounded-2xl border border-foreground/10 hover:border-accent/30 transition-colors"
>
<div class="flex justify-between items-center mb-3">
<div class="flex items-center gap-2">
<a
href={comment.author.url || '#'}
target={comment.author.url ? '_blank' : ''}
rel="noopener noreferrer"
class="font-bold text-heading1 hover:text-accent transition-colors"
>
{comment.author.login}
</a>
{#if comment.author.login === 'FoggyMtnDrifter'}
<span
class="bg-accent/10 text-accent text-[10px] px-1.5 py-0.5 rounded-full font-bold border border-accent/20"
>ADMIN</span
>
{/if}
</div>
<span class="text-xs text-foreground/50 font-mono">
{new Date(comment.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</div>
<div
class="prose prose-invert max-w-none text-sm text-foreground/90 leading-relaxed overflow-hidden break-words"
>
{@html comment.bodyHTML}
</div>
</div>
</div>
</div>
<!-- Replies -->
{#if comment.replies && comment.replies.nodes.length > 0}
<div
class="ml-5 sm:ml-14 mt-4 space-y-4 border-l-2 border-accent/10 pl-4 sm:pl-6"
>
{#each comment.replies.nodes as reply}
<div class="flex gap-3">
<img
src={reply.author.avatarUrl}
alt={reply.author.login}
class="w-8 h-8 rounded-full border border-accent/20 flex-shrink-0"
/>
<div
class="flex-1 min-w-0 bg-foreground/3 px-4 py-3 rounded-xl border border-foreground/5 hover:border-accent/20 transition-colors"
>
<div class="flex justify-between items-center mb-3">
<div class="flex items-center gap-2">
<a
href={reply.author.url || '#'}
target={reply.author.url ? '_blank' : ''}
rel="noopener noreferrer"
class="font-semibold text-sm text-heading1 hover:text-accent transition-colors"
>
{reply.author.login}
</a>
{#if reply.author.login === 'FoggyMtnDrifter'}
<span
class="bg-accent/10 text-accent text-[10px] px-1.5 py-0.5 rounded-full font-bold border border-accent/20"
>ADMIN</span
>
{/if}
</div>
<span class="text-[10px] text-foreground/50 font-mono">
{new Date(reply.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</div>
<div
class="prose prose-invert max-w-none text-sm text-foreground/90 leading-relaxed break-words"
>
{@html reply.bodyHTML}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
{#if comments.length === 0}
<div
class="text-center py-12 bg-foreground/3 rounded-2xl border border-foreground/5 border-dashed"
>
<p class="text-foreground/60">No comments yet.</p>
<p class="text-sm text-accent mt-1">Be the first to post!</p>
</div>
{/if}
</div>
{/if}
</div>
<style>
/* Add any custom styles here if not using Tailwind for everything */
/* Target links inside the comments, ignoring global prose settings if needed or enhancing them */
div.prose :global(a) {
color: var(--color-accent); /* Use CSS variable for accent color */
text-decoration: underline;
font-weight: 500;
text-underline-offset: 2px;
}
div.prose :global(p) {
margin-top: 0.5em;
margin-bottom: 0.5em;
margin-left: 0;
margin-right: 0;
}
/* Override global prose margins that might be applied to direct children */
div.prose > :global(*) {
margin-left: 0 !important;
margin-right: 0 !important;
}
div.prose :global(a:hover) {
text-decoration-thickness: 2px;
}
div.prose :global(p:first-child) {
margin-top: 0;
}
div.prose :global(p:last-child) {
margin-bottom: 0;
}
</style>
+73
View File
@@ -0,0 +1,73 @@
---
---
<div
id="cookie-consent"
class="fixed bottom-4 left-4 z-50 hidden max-w-sm rounded-xl border border-accent/20 bg-background/95 p-4 shadow-lg backdrop-blur-sm transition-all duration-300"
role="alert"
>
<div class="flex flex-col gap-3">
<p class="text-sm text-foreground/90">
I use essential cookies to ensure my website functions properly. By continuing to
use this site, you agree to my <a
href="/legal/privacy-policy"
class="text-accent hover:underline">Privacy Policy</a
>.
</p>
<button
id="dismiss-cookie-consent"
class="self-start rounded-lg bg-accent/10 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/20 active:bg-accent/30 transition-colors"
>
Got it
</button>
</div>
</div>
<script>
// Function to set a cookie
function setCookie(name: string, value: string, days: number) {
let expires = ''
if (days) {
const date = new window.Date()
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000)
expires = '; expires=' + date.toUTCString()
}
document.cookie = name + '=' + (value || '') + expires + '; path=/'
}
// Function to get a cookie
function getCookie(name: string) {
const nameEQ = name + '='
const ca = document.cookie.split(';')
for (let i = 0; i < ca.length; i++) {
let c = ca[i]
while (c.charAt(0) == ' ') c = c.substring(1, c.length)
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length)
}
return null
}
const CONSENT_COOKIE_NAME = 'cookie-consent-dismissed'
const component = document.getElementById('cookie-consent')
const dismissButton = document.getElementById('dismiss-cookie-consent')
// Check if the consent cookie exists
if (!getCookie(CONSENT_COOKIE_NAME)) {
// Show the popup after a small delay for a smoother entrance
setTimeout(() => {
component?.classList.remove('hidden')
}, 500)
}
// Handle dismiss click
dismissButton?.addEventListener('click', () => {
// Set cookie for 365 days
setCookie(CONSENT_COOKIE_NAME, 'true', 365)
// Hide the popup
component?.classList.add('opacity-0', 'translate-y-4')
setTimeout(() => {
component?.classList.add('hidden')
}, 300)
})
</script>
+12
View File
@@ -0,0 +1,12 @@
---
interface Props {
text: string
}
const { text } = Astro.props
---
<div class="flex gap-3 items-center my-4">
<hr class="flex-1 bg-accent h-0.5 border-none" />
<span class="shrink-0 whitespace-nowrap">{text}</span>
<hr class="flex-1 bg-accent h-0.5 border-none" />
</div>
+20
View File
@@ -0,0 +1,20 @@
---
import siteConfig from '~/site.config'
import SocialLinks from '~/components/SocialLinks.astro'
---
<footer
class="mt-auto pt-9 md:pt-11 grow-0 flex flex-col gap-6 items-center justify-between max-w-full text-foreground/80"
>
{siteConfig.socialLinks && <SocialLinks socialLinks={siteConfig.socialLinks} />}
<div class="flex flex-col md:flex-row flex-wrap flex-1 items-center justify-center">
<span class="my-1">
© 2025 {siteConfig.author}
</span>
<span class="mx-5 hidden md:block"> :: </span>
<span class="my-1">
<a class="underline mx-2" href="/legal/terms-of-use">Terms of Use</a>
<a class="underline mx-2" href="/legal/privacy-policy">Privacy Policy</a>
</span>
</div>
</footer>
+307
View File
@@ -0,0 +1,307 @@
---
import {
differenceInCalendarDays,
eachDayOfInterval,
formatISO,
getDay,
nextDay,
parseISO,
subWeeks,
getYear,
getMonth,
isValid,
} from 'date-fns'
import type {
WeekdayIndex,
GitHubActivityApiResponse,
GitHubActivityDay,
GitHubActivityWeek,
GitHubActivityMonthLabel,
} from '~/types'
interface Props {
username: string
year?: number
}
function range(n: number) {
return [...Array(n).keys()]
}
async function fetchData(
username: string,
year: number | 'last',
): Promise<GitHubActivityApiResponse> {
function validateActivities(activities: Array<GitHubActivityDay>) {
if (activities.length === 0) {
throw new Error('Activity data must not be empty.')
}
for (const { date, count } of activities) {
if (!isValid(parseISO(date))) {
throw new Error(`Activity date '${date}' is not a valid ISO 8601 date string.`)
}
if (count < 0) {
throw new RangeError(`Activity count must not be negative, found ${count}.`)
}
}
}
const apiUrl = 'https://github-contributions-api.jogruber.de/v4/'
const response = await fetch(`${apiUrl}${username}?y=${String(year)}`)
const data = (await response.json()) as GitHubActivityApiResponse
if (!response.ok) {
const message = data.error || 'Unknown error'
throw Error(`Fetching GitHub contribution data for "${username}" failed: ${message}`)
}
validateActivities(data.contributions)
return data
}
function calcColorScale([start, end]: [string, string], steps: number): Array<string> {
return range(steps).map((i) => {
switch (i) {
case 0:
return start
case steps - 1:
return end
default: {
const pos = (i / (steps - 1)) * 100
return `color-mix(in oklab, ${end} ${parseFloat(pos.toFixed(2))}%, ${start})`
}
}
})
}
function fillHoles(activities: Array<GitHubActivityDay>): Array<GitHubActivityDay> {
const calendar = new Map<string, GitHubActivityDay>(activities.map((a) => [a.date, a]))
const firstActivity = activities[0] as GitHubActivityDay
const lastActivity = activities[activities.length - 1] as GitHubActivityDay
return eachDayOfInterval({
start: parseISO(firstActivity.date),
end: parseISO(lastActivity.date),
}).map((day) => {
const date = formatISO(day, { representation: 'date' })
if (calendar.has(date)) {
return calendar.get(date) as GitHubActivityDay
}
return {
date,
count: 0,
level: 0,
}
})
}
function groupByWeeks(
activities: Array<GitHubActivityDay>,
weekStart: WeekdayIndex = 0, // 0 = Sunday
): Array<GitHubActivityWeek> {
const normalizedActivities = fillHoles(activities)
// Determine the first date of the calendar. If the first date is not the
// passed weekday, the respective weekday one week earlier is used.
const firstActivity = normalizedActivities[0] as GitHubActivityDay
const firstDate = parseISO(firstActivity.date)
const firstCalendarDate =
getDay(firstDate) === weekStart
? firstDate
: subWeeks(nextDay(firstDate, weekStart), 1)
// To correctly group activities by week, it is necessary to left-pad the list
// because the first date might not be set start weekday.
const paddedActivities = [
...(Array(differenceInCalendarDays(firstDate, firstCalendarDate)).fill(
undefined,
) as Array<GitHubActivityDay>),
...normalizedActivities,
]
const numberOfWeeks = Math.ceil(paddedActivities.length / 7)
// Finally, group activities by week
return [...Array(numberOfWeeks).keys()].map((weekIndex) =>
paddedActivities.slice(weekIndex * 7, weekIndex * 7 + 7),
)
}
function getMonthLabels(
weeks: Array<GitHubActivityWeek>,
monthNames: Array<string>,
): Array<GitHubActivityMonthLabel> {
return weeks
.reduce<Array<GitHubActivityMonthLabel>>((labels, week, weekIndex) => {
const firstActivity = week.find((activity) => activity !== undefined)
if (!firstActivity) {
throw new Error(`Unexpected error: Week ${weekIndex + 1} is empty.`)
}
const month = monthNames[getMonth(parseISO(firstActivity.date))]
if (!month) {
const monthName = new Date(firstActivity.date).toLocaleString('en-US', {
month: 'short',
})
throw new Error(`Unexpected error: undefined month label for ${monthName}.`)
}
const prevLabel = labels[labels.length - 1]
if (weekIndex === 0 || !prevLabel || prevLabel.label !== month) {
return [...labels, { weekIndex, label: month }]
}
return labels
}, [])
.filter(({ weekIndex }, index, labels) => {
const minWeeks = 3
// Skip the first month label if there is not enough space to the next one.
if (index === 0) {
return labels[1] && labels[1].weekIndex - weekIndex >= minWeeks
}
// Skip the last month label if there is not enough data in that month
if (index === labels.length - 1) {
return weeks.slice(weekIndex).length >= minWeeks
}
return true
})
}
const { username, year = 'last' } = Astro.props
const data = await fetchData(username, year)
const themeFromColorscheme: [string, string] = [
'var(--theme-background)',
'var(--theme-accent)',
]
const totalCount = year === 'last' ? data.total.lastYear : data.total[year]
const maxLevel = 4
const blockMargin = 4
const labelMargin = 8
const blockRadius = 2
const blockSize = 12
const fontSize = 14
const hideColorLegend = false
const hideMonthLabels = false
const hideTotalCount = false
const weekStart = 0 // 0 = Sunday, 1 = Monday, etc.
const colorScale = calcColorScale(themeFromColorscheme, maxLevel + 1)
const activities = data.contributions
const firstActivity = activities[0]
const activityYear = getYear(parseISO(firstActivity.date))
const weeks = groupByWeeks(activities, weekStart)
const labels = {
months: [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
],
totalCount: `{{count}} contributions in ${year === 'last' ? 'the last year' : '{{year}}'}`,
legend: {
less: 'Less',
more: 'More',
},
}
const labelHeight = hideMonthLabels ? 0 : fontSize + labelMargin
const width = weeks.length * (blockSize + blockMargin) - blockMargin
const height = labelHeight + (blockSize + blockMargin) * 7 - blockMargin
---
<article
id="github-activity-calendar"
class="w-max max-w-full flex flex-col gap-2 text-sm"
>
<div
class="max-w-full overflow-x-auto pt-0.5"
style={{
// Don't cover the calendar with the scrollbar.
scrollbarGutter: 'stable',
}}
>
<svg
class="block visible"
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
>
{
!hideMonthLabels && (
<g>
{getMonthLabels(weeks, labels.months).map(({ label, weekIndex }) => (
<text
x={(blockSize + blockMargin) * weekIndex}
y={0}
dominant-baseline="hanging"
fill="currentColor"
>
{label}
</text>
))}
</g>
)
}
{
weeks.map((week, weekIndex) => (
<g transform={`translate(${(blockSize + blockMargin) * weekIndex}, 0)`}>
{week.map((activity, dayIndex) => {
if (!activity) return null
return (
<rect
class="stroke-foreground/10"
x={0}
y={labelHeight + (blockSize + blockMargin) * dayIndex}
width={blockSize}
height={blockSize}
rx={blockRadius}
ry={blockRadius}
fill={colorScale[activity.level]}
data-date={activity.date}
data-level={activity.level}
/>
)
})}
</g>
))
}
</svg>
</div>
{
!(hideTotalCount && hideColorLegend) && (
<footer class="flex flex-col sm:flex-row sm:justify-between gap-x-1 gap-y-2">
{!hideTotalCount && (
<div>
{labels.totalCount
? labels.totalCount
.replace('{{count}}', String(totalCount))
.replace('{{year}}', String(activityYear))
: `${totalCount} activities in ${activityYear}`}
</div>
)}
{!hideColorLegend && (
<div class="flex items-center gap-[3px]">
<span class="mr-1.5">{labels.legend.less}</span>
{range(maxLevel + 1).map((level) => (
<svg width={blockSize} height={blockSize}>
<rect
class="stroke-foreground/10"
width={blockSize}
height={blockSize}
fill={colorScale[level]}
rx={blockRadius}
ry={blockRadius}
/>
</svg>
))}
<span class="ml-1.5">{labels.legend.more}</span>
</div>
)}
</footer>
)
}
</article>
+100
View File
@@ -0,0 +1,100 @@
---
import siteConfig from '~/site.config'
import LightDarkAutoButton from '~/components/LightDarkAutoButton.astro'
import Search from '~/components/Search.astro'
import SelectTheme from '~/components/SelectTheme.astro'
import NavLink from '~/components/NavLink.astro'
const lightDarkAutoTheme = siteConfig.themes.mode === 'light-dark-auto'
const selectTheme =
siteConfig.themes.mode === 'select' && siteConfig.themes.include.length > 1
---
<header>
<div class="relative flex items-center justify-between bg-accent/10 rounded-xl">
<a
id="logo"
href="/"
class="block px-4 py-1.5 max-w-full no-underline items-center bg-accent text-background font-bold rounded-xl"
>
{siteConfig.title}
</a>
<div class="flex items-center gap-3 sm:mr-3">
<Search trailingSlashes={siteConfig.trailingSlashes} />
{lightDarkAutoTheme && <LightDarkAutoButton />}
{selectTheme && <SelectTheme />}
<nav id="nav-mobile" aria-label="Menu" class="p-0 text-accent sm:hidden">
<button
id="nav-mobile-button"
class="px-3 py-1 h-full cursor-pointer border-2 rounded-xl bg-background"
type="button"
aria-expanded="false"
aria-controls="nav-menu-list"
>
</button>
<ul
id="nav-mobile-list"
class="invisible absolute flex flex-col bg-background shadow text-accent border-2 m-0 p-2.5 top-11.5 left-auto right-0 z-50 rounded-xl"
>
{
siteConfig.navLinks.map((link) => (
<li class="p-1" aria-expanded="false">
<NavLink link={link} />
</li>
))
}
<li class="p-1" aria-expanded="false">
<button
type="button"
class="bmc-trigger underline cursor-pointer text-left w-full"
>
Coffee?
</button>
</li>
</ul>
</nav>
</div>
</div>
<nav aria-label="Menu" class="p-0 mt-4 ml-0.5 text-accent hidden sm:block">
<ul class="flex flex-row text-accent">
{
siteConfig.navLinks.map((link) => (
<li class="mr-5" aria-expanded="true">
<NavLink link={link} />
</li>
))
}
<li class="mr-5" aria-expanded="true">
<button type="button" class="bmc-trigger underline cursor-pointer">
Coffee?
</button>
</li>
</ul>
</nav>
</header>
<script>
const navMobileButton = document.getElementById('nav-mobile-button')
const navMobileList = document.getElementById('nav-mobile-list')
const navMobileListItems = navMobileList?.querySelectorAll('li')
const toggleNavMobileMenu = (action: 'on' | 'off' | 'toggle') => {
let isNowOpen: boolean = false
if (action === 'on') {
isNowOpen = true
navMobileList?.classList.remove('invisible')
} else if (action === 'off') {
isNowOpen = false
navMobileList?.classList.add('invisible')
} else {
isNowOpen = !navMobileList?.classList.toggle('invisible')
}
navMobileButton?.setAttribute('aria-expanded', isNowOpen ? 'true' : 'false')
navMobileListItems?.forEach((listItem) => {
listItem.setAttribute('aria-expanded', isNowOpen ? 'true' : 'false')
})
}
navMobileButton?.addEventListener('click', (_ev) => {
toggleNavMobileMenu('toggle')
})
</script>
+37
View File
@@ -0,0 +1,37 @@
---
import { Image } from 'astro:assets'
import GitHubActivityCalendar from '~/components/GitHubActivityCalendar.astro'
import type { FrontmatterImage } from '~/types'
interface Props {
avatarImage?: FrontmatterImage
githubCalendar?: string
}
const { avatarImage, githubCalendar } = Astro.props
---
<div class="my-6 md:mx-2">
<div
class="flex flex-col sm:flex-row items-center justify-around sm:gap-5 max-w-full mb-6"
>
{
avatarImage && (
<Image
priority
src={avatarImage.src}
alt={avatarImage.alt}
class="rounded-full border-8 border-accent/15 size-36 aspect-square box-content shrink-0"
widths={[288]}
height={144}
width={144}
/>
)
}
<div>
<div class:list={['m-4 max-w-full prose']}>
<slot />
</div>
</div>
</div>
{githubCalendar && <GitHubActivityCalendar username={githubCalendar} />}
</div>
+71
View File
@@ -0,0 +1,71 @@
---
import IconSun from '~/icons/sun.svg'
import IconMoon from '~/icons/moon.svg'
import IconSunMoon from '~/icons/sun-moon.svg'
import siteConfig from '~/site.config'
let lightTheme = siteConfig.themes.include[0]
let darkTheme = siteConfig.themes.include[1]
---
<button
id="theme-change-button"
class="block ml-auto"
data-light={lightTheme}
data-dark={darkTheme}
>
<IconSun id="icon-light" class="hidden size-6 text-accent" />
<IconMoon id="icon-dark" class="hidden size-6 text-accent" />
<IconSunMoon id="icon-auto" class="hidden size-6 text-accent" />
</button>
<script>
const themeButton = document.getElementById('theme-change-button')
const lightThemeId = themeButton?.getAttribute('data-light')
const darkThemeId = themeButton?.getAttribute('data-dark')
const sunIcon = themeButton?.querySelector('#icon-light')
const moonIcon = themeButton?.querySelector('#icon-dark')
const sunMoonIcon = themeButton?.querySelector('#icon-auto')
const updateIcons = (theme: string) => {
if (theme === lightThemeId) {
sunIcon?.classList.remove('hidden')
moonIcon?.classList.add('hidden')
sunMoonIcon?.classList.add('hidden')
} else if (theme === darkThemeId) {
sunIcon?.classList.add('hidden')
moonIcon?.classList.remove('hidden')
sunMoonIcon?.classList.add('hidden')
} else {
sunIcon?.classList.add('hidden')
moonIcon?.classList.add('hidden')
sunMoonIcon?.classList.remove('hidden')
}
}
let currentTheme = localStorage.getItem('data-theme') || 'auto'
updateIcons(currentTheme)
themeButton?.addEventListener('click', () => {
// Toggle between light, dark, and auto themes
// Only need to update localStorage because there should be a listener in Layout.astro
const currentTheme = localStorage.getItem('data-theme') || darkThemeId
let newTheme
if (currentTheme === lightThemeId) {
newTheme = darkThemeId
} else if (currentTheme === darkThemeId) {
newTheme = 'auto'
} else {
newTheme = lightThemeId
}
if (newTheme) {
updateIcons(newTheme)
localStorage.setItem('data-theme', newTheme)
if (newTheme === 'auto') {
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)')
document.documentElement.setAttribute(
'data-theme',
prefersDarkScheme.matches ? darkThemeId || 'auto' : lightThemeId || 'auto',
)
} else {
document.documentElement.setAttribute('data-theme', newTheme)
}
}
})
</script>
@@ -0,0 +1,48 @@
---
---
<script is:inline>
;(function loadTheme() {
const pageDefaultTheme = document.documentElement.getAttribute('data-theme')
const pageDarkTheme = document.documentElement.getAttribute('data-dark-theme')
const pageLightTheme = document.documentElement.getAttribute('data-light-theme')
const pageThemeHash = document.documentElement.getAttribute('data-theme-hash')
if (!pageDefaultTheme || !pageDarkTheme || !pageLightTheme || !pageThemeHash) {
throw new Error('Theme attributes are required.')
}
const getStoredTheme = () => localStorage.getItem('data-theme')
let storedTheme = getStoredTheme()
const storedThemeHash = localStorage.getItem('data-theme-hash')
const themeHashMatches = storedThemeHash === pageThemeHash
if (!storedTheme || !storedThemeHash || !themeHashMatches) {
// Should be the first time loading the website
localStorage.setItem('data-theme', pageDefaultTheme)
localStorage.setItem('data-theme-hash', pageThemeHash)
}
if (
themeHashMatches &&
storedTheme &&
storedTheme !== 'auto' &&
storedTheme !== pageDefaultTheme
) {
// The stored theme is different from the default theme, apply it
document.documentElement.setAttribute('data-theme', storedTheme)
} else if (pageDefaultTheme === 'auto' || storedTheme === 'auto') {
// If the default or stored theme is 'auto', apply the system preference
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)')
document.documentElement.setAttribute(
'data-theme',
prefersDarkScheme.matches ? pageDarkTheme : pageLightTheme,
)
prefersDarkScheme.addEventListener('change', (e) => {
if (getStoredTheme() === 'auto') {
const newTheme = e.matches ? pageDarkTheme : pageLightTheme
document.documentElement.setAttribute('data-theme', newTheme)
}
})
}
})()
</script>
+18
View File
@@ -0,0 +1,18 @@
---
import type { NavLink } from '~/types'
interface Props {
link: NavLink
}
const { link } = Astro.props
---
<a
class="underline"
rel={link.external ? 'noopener noreferrer' : undefined}
target={link.external ? '_blank' : undefined}
href={link.url}
>
{link.name}
</a>
+29
View File
@@ -0,0 +1,29 @@
---
interface Props {
title?: string
titlePieces?: string[]
}
const { title, titlePieces } = Astro.props
const splitPath: string[] = Astro.url.pathname
.split('/')
.filter(Boolean) // Remove empty parts
.map((part) => decodeURIComponent(part)) // For Unicode chars kept by github-slugger
const pieces = titlePieces ?? splitPath
if (pieces.length === 0) {
pieces.push('home')
}
const titleFromPieces = pieces.map((part) => `/ ${part}`)
---
<div
class="border-0 border-accent/30 bg-accent/6 rounded-xl inline-block py-2.5 pl-3 pr-4 mt-5 md:mt-8"
>
<h1 class="text-accent text-2xl font-semibold flex flex-wrap gap-y-2 gap-x-3.5">
{title ? <span>{title}</span> : titleFromPieces.map((part) => <span>{part}</span>)}
</h1>
</div>
+43
View File
@@ -0,0 +1,43 @@
---
import IconChevronsLeft from '~/icons/chevrons-left.svg'
import IconChevronsRight from '~/icons/chevrons-right.svg'
interface Props {
prevLink?: string
prevText?: string
nextLink?: string
nextText?: string
}
const { prevLink, prevText, nextLink, nextText } = Astro.props
---
<div class="my-5 flex flex-row flex-wrap gap-3 justify-center">
{
prevLink && (
<a
href={prevLink}
aria-label="Previous Page"
class="button flex-row items-center justify-center w-full !pr-3.5 sm:w-min"
>
<IconChevronsLeft class="mr-2 text-xl size-5" />
{prevText || 'Previous'}
</a>
)
}
{
nextLink && (
<a
href={nextLink}
aria-label="Next Page"
class:list={[
'button flex-row items-center justify-center w-full !pl-3.5 sm:w-min',
prevLink && 'ml-auto',
]}
>
{nextText || 'Next'}
<IconChevronsRight class="ml-2 text-xl size-5" />
</a>
)
}
</div>
+31
View File
@@ -0,0 +1,31 @@
---
import type { FrontmatterImage } from '~/types'
import { Image } from 'astro:assets'
interface Props {
avatarImage?: FrontmatterImage
}
const { avatarImage } = Astro.props
---
<div
class="flex flex-col sm:flex-row items-center justify-around sm:gap-5 max-w-full mb-6 py-10 bg-accent/8 p-4 rounded-xl"
>
{
avatarImage && (
<Image
src={avatarImage.src}
alt={avatarImage.alt}
class="rounded-full border-8 border-accent/15 size-36 aspect-square box-content shrink-0"
widths={[288]}
height={144}
width={144}
/>
)
}
<div>
<div class:list={['m-4 max-w-full prose']}>
<slot />
</div>
</div>
</div>
+62
View File
@@ -0,0 +1,62 @@
---
import { dateString, SeriesGroup } from '~/utils'
import { render, type CollectionEntry } from 'astro:content'
import type { Collation } from '~/types'
interface Props {
post: CollectionEntry<'posts'>
class?: string
}
const { post, class: className } = Astro.props
const { remarkPluginFrontmatter } = await render(post)
const { minutesRead } = remarkPluginFrontmatter
const seriesFrontmatter = post.data.series
let series: Collation<'posts'> | undefined
let seriesPostNumber: number | undefined
let seriesTotal: number | undefined
if (seriesFrontmatter) {
const seriesGroup = await SeriesGroup.build()
series = seriesGroup.match(seriesFrontmatter)
if (!series) {
// This should never happen if the series data is correct
throw new Error(`Series "${seriesFrontmatter}" not found`)
}
seriesPostNumber = series.entries.findIndex((p) => p.id === post.id) + 1
seriesTotal = series.entries.length
}
---
<div class:list={[className, 'text-foreground/80']}>
<div
class="flex flex-col gap-4 sm:gap-2 sm:flex-wrap items-start sm:items-center sm:flex-row"
>
{
series && seriesPostNumber && seriesTotal && (
<a
class="shrink-0 rounded-2xl py-[3px] px-3.5 bg-foreground/1 me-2.5 hover:bg-foreground/5 transition-colors border-1 border-foreground/16 hover:border-foreground/20"
href={series.url}
>
{series.title} {seriesPostNumber} / {seriesTotal}
</a>
)
}
<div class="shrink-0 pl-0.5 text-[17px] sm:text-base">
<time>{dateString(post.data.published)}</time>
{
post.data.author && (
<span class="before:content-['·'] before:font-bold before:inline-block before:mx-0.5">
{post.data.author}
</span>
)
}
{
minutesRead && (
<span class="before:content-['·'] before:inline-block before:mx-0.5">
{minutesRead}
</span>
)
}
</div>
</div>
</div>
+46
View File
@@ -0,0 +1,46 @@
---
import IconChevronsRight from '~/icons/chevrons-right.svg'
import type { CollectionEntry } from 'astro:content'
import { render } from 'astro:content'
import Tags from '~/components/Tags.astro'
import PostInfo from '~/components/PostInfo.astro'
import type { Collation } from '~/types'
import { TagsGroup } from '~/utils'
interface Props {
post: CollectionEntry<'posts'>
}
const { post } = Astro.props
const { remarkPluginFrontmatter } = await render(post)
const description = remarkPluginFrontmatter.description || post.data.description
let tags: Collation<'posts'>[] | undefined
if (post.data.tags && post.data.tags.length > 0) {
const tagsGroup = await TagsGroup.build()
tags = tagsGroup.matchMany(post.data.tags)
}
const samePage = Astro.url.pathname === `/posts/${post.id}`
const articleLink = samePage ? `#${post.id}` : `/posts/${post.id}`
---
<article class="w-full py-5 my-1 md:my-4 border-accent/10">
<h1 class="mb-3 text-2xl text-heading1 font-semibold">
<a href={articleLink}>{post.data.title}</a>
</h1>
<PostInfo post={post} class="mb-3 mt-4" />
{description && <p class="pl-0.5 my-4 text-base/7 text-foreground">{description}</p>}
{
tags && (
<div class="mb-6">
<Tags tags={tags} />
</div>
)
}
<div>
<a class="button flex items-center !pl-3.5" href={articleLink}>
{samePage ? 'Continue' : 'Read'}
<IconChevronsRight class="size-5 ml-2" />
</a>
</div>
</article>
+94
View File
@@ -0,0 +1,94 @@
---
import IconChevronUp from '~/icons/chevron-up.svg'
---
<button
hidden
class="scroll-up size-11 fixed flex items-center justify-center bg-background z-100 bottom-3 right-3 rounded-full border-2 border-accent/20 hover:bg-accent/10 md:size-12 md:bottom-5 md:right-5 lg:size-13 lg:bottom-6 lg:right-6"
>
<IconChevronUp class="text-accent/50 size-8 lg:size-9" />
</button>
<script>
function transitionHiddenElement(
element: HTMLElement,
transitionProperty: string,
transitionClass: string,
) {
const listener = (e: TransitionEvent) => {
if (e.target === element && e.propertyName === transitionProperty) {
element.setAttribute('hidden', 'true')
element.removeEventListener('transitionend', listener)
}
}
return {
show() {
element.removeEventListener('transitionend', listener)
element.removeAttribute('hidden')
// Force a browser re-paint
const _reflow = element.offsetHeight
element.classList.add(transitionClass)
},
hide() {
element.addEventListener('transitionend', listener)
element.classList.remove(transitionClass)
},
}
}
function init() {
const button = document.querySelector('button.scroll-up') as HTMLButtonElement
const topHeading = document.querySelector('h1')
const topOffset = topHeading ? topHeading.offsetTop : 0
if (!button) {
console.warn('Scroll up button not found in the document.')
return
}
button.addEventListener('click', () => {
window.scrollTo({ top: topOffset, behavior: 'smooth' })
})
// Use IntersectionObserver to show/hide the button
const prose = document.querySelector('article div.prose')
if (!prose) {
console.warn('Prose element not found in the article.')
return
}
const btnControl = transitionHiddenElement(button, 'opacity', 'active')
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
btnControl.show()
} else {
btnControl.hide()
}
})
},
// Only consider the top 5% of the viewport
{ rootMargin: `0px 0px -95% 0px` },
)
observer.observe(prose)
}
init()
</script>
<style>
/* When button appears, slide it in from the bottom */
button.scroll-up {
/* also add a translate effect */
transform: translateY(0px);
/* and a transition */
opacity: 1;
transition:
transform 0.3s ease-in-out,
opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
button.scroll-up:not(.active) {
transform: translateY(10px);
opacity: 0;
}
</style>
+221
View File
@@ -0,0 +1,221 @@
---
import '@pagefind/default-ui/css/ui.css'
import IconCircleX from '~/icons/circle-x.svg'
import IconSearch from '~/icons/search.svg'
const { trailingSlashes = false } = Astro.props
---
<site-search class="ms-auto" id="search" data-trailing-slashes={trailingSlashes}>
<button
class="hover:text-accent flex cursor-pointer items-center justify-center rounded-md"
aria-keyshortcuts="Control+K Meta+K"
data-open-modal
disabled
>
<IconSearch class="size-6 text-accent" />
<span class="sr-only">Open Search</span>
</button>
<dialog
aria-label="search"
class="text-foreground! bg-background max-h-5/6 min-h-48 w-7/8 sm:w-5/6 max-w-xl border-double! border-4 border-accent/30 shadow-sm backdrop:backdrop-blur-sm open:flex mx-auto mt-10 sm:mt-16 mb-auto rounded-xl"
>
<div class="dialog-frame flex grow flex-col gap-4 p-6 pt-6 max-w-full">
<button class="cursor-pointer fixed top-2 right-2 rounded-full" data-close-modal>
<IconCircleX class="size-6 text-accent/50" />
</button>
{
import.meta.env.DEV ? (
<div class="mx-auto text-center">
<p>
Search is only available in production builds. <br />
Try building and previewing the site to test it out locally.
</p>
</div>
) : (
<div class="search-container">
<div id="pagefind-search" class="max-w-full" />
</div>
)
}
</div>
</dialog>
</site-search>
<style is:global>
:root {
--pagefind-ui-font: inherit;
--pagefind-ui-primary: var(--theme-accent);
--pagefind-ui-text: var(--theme-foreground);
--pagefind-ui-background: var(--theme-background);
--pagefind-ui-border: var(--theme-accent);
--pagefind-ui-border-width: 2px;
}
.pagefind-ui__results-area {
margin: 16px 0;
}
.pagefind-ui__result {
overflow-x: scroll !important;
}
.pagefind-ui__result-inner {
max-width: 100% !important;
}
.pagefind-ui__result-inner mark {
background-color: var(--theme-accent) !important;
color: var(--theme-background) !important;
font-weight: 600 !important;
padding: 0 3px !important;
border-radius: 6px !important;
}
.pagefind-ui__result-nested .pagefind-ui__result-link:before {
content: '>' !important;
left: -16px !important;
right: unset !important;
color: var(--theme-accent) !important;
font-weight: 600 !important;
}
</style>
<script>
class SiteSearch extends HTMLElement {
#closeBtn: HTMLButtonElement | null
#dialog: HTMLDialogElement | null
#dialogFrame: HTMLDivElement | null
#openBtn: HTMLButtonElement | null
#controller: AbortController
trailingSlashes: boolean
stripTrailingSlash: (path: string) => string
formatURL: (path: string) => string
constructor() {
super()
this.#openBtn = this.querySelector<HTMLButtonElement>('button[data-open-modal]')
this.#closeBtn = this.querySelector<HTMLButtonElement>('button[data-close-modal]')
this.#dialog = this.querySelector<HTMLDialogElement>('dialog')
this.#dialogFrame = this.querySelector('.dialog-frame')
this.#controller = new AbortController()
this.trailingSlashes = this.dataset.trailingSlashes === 'true' ? true : false
this.stripTrailingSlash = (path: string) => path.replace(/(.)\/(#.*)?$/, '$1$2')
this.formatURL = !this.trailingSlashes
? this.stripTrailingSlash
: (path: string) => path
// Set up events
if (this.#openBtn) {
this.#openBtn.addEventListener('click', this.openModal)
this.#openBtn.disabled = false
} else {
console.warn('Search button not found')
}
if (this.#closeBtn) {
this.#closeBtn.addEventListener('click', this.closeModal)
} else {
console.warn('Close button not found')
}
if (this.#dialog) {
this.#dialog.addEventListener('close', () => {
window.removeEventListener('click', this.onWindowClick)
const body = document.querySelector('body')
body?.classList.remove('overflow-hidden')
})
} else {
console.warn('Dialog not found')
}
// only add pagefind in production
if (import.meta.env.DEV) return
const onIdle = window.requestIdleCallback || ((cb) => setTimeout(cb, 1))
onIdle(async () => {
const { PagefindUI } = await import('@pagefind/default-ui')
new PagefindUI({
baseUrl: import.meta.env.BASE_URL,
bundlePath: import.meta.env.BASE_URL.replace(/\/$/, '') + '/pagefind/',
element: '#pagefind-search',
showImages: false,
showSubResults: true,
processResult: (result: {
url: string
sub_results: Array<{ url: string }>
}) => {
// Ensure links in search results match the trailing slashes setting
result.url = this.formatURL(result.url)
result.sub_results = result.sub_results.map((res) => {
res.url = this.formatURL(res.url)
return res
})
return result
},
})
})
}
connectedCallback() {
// window events, requires cleanup
window.addEventListener('keydown', this.onWindowKeydown, {
signal: this.#controller.signal,
})
}
disconnectedCallback() {
this.#controller.abort()
}
openModal = (event?: MouseEvent) => {
if (!this.#dialog) {
console.warn('Dialog not found')
return
}
const body = document.querySelector('body')
body?.classList.add('overflow-hidden')
this.#dialog.showModal()
this.querySelector('input')?.focus()
event?.stopPropagation()
window.addEventListener('click', this.onWindowClick, {
signal: this.#controller.signal,
})
}
closeModal = () => {
this.#dialog?.close()
}
onWindowClick = (event: MouseEvent) => {
// check if it's a link
const isLink = 'href' in (event.target || {})
// make sure the click is either a link or outside of the dialog
if (
isLink ||
(document.body.contains(event.target as Node) &&
!this.#dialogFrame?.contains(event.target as Node))
) {
this.closeModal()
}
}
onWindowKeydown = (e: KeyboardEvent) => {
if (!this.#dialog) {
console.warn('Dialog not found')
return
}
// check if it's the Control+K or ⌘+K shortcut
if ((e.metaKey === true || e.ctrlKey === true) && e.key === 'k') {
this.#dialog.open ? this.closeModal() : this.openModal()
e.preventDefault()
}
}
}
customElements.define('site-search', SiteSearch)
</script>
+193
View File
@@ -0,0 +1,193 @@
---
import '@pagefind/default-ui/css/ui.css'
import IconPalette from '~/icons/palette.svg'
import IconCircleX from '~/icons/circle-x.svg'
import siteConfig from '~/site.config'
function kebabToTitleCase(str: string): string {
return str
.split('-') // Split the string into words
.map((word) => word.charAt(0).toUpperCase() + word.slice(1)) // Capitalize each word
.join(' ') // Join the words with a space
}
---
<select-theme class="ms-auto" id="search">
<button
class="hover:text-accent flex cursor-pointer items-center justify-center rounded-md"
aria-keyshortcuts="Control+K Meta+K"
data-open-modal
disabled
>
<IconPalette class="size-6 text-accent" />
<span class="sr-only">Select Theme</span>
</button>
<dialog
aria-label="select-theme"
class="text-foreground! bg-background max-h-5/6 max-w-5/6 border-double! border-4 border-accent/30 shadow-sm backdrop:backdrop-blur-sm open:flex mx-auto mt-16 mb-auto rounded-xl"
>
<div class="dialog-frame flex grow flex-col gap-4 px-10 py-1 max-w-full">
<button
aria-roledescription="close"
class="cursor-pointer fixed top-2 right-2 rounded-full"
data-close-modal
>
<IconCircleX class="size-6 text-accent/50" />
</button>
<ul
id="theme-change-list"
class="flex flex-col bg-background text-accent m-0 p-2 rounded-xl"
>
{
siteConfig.themes.include.map((theme) => (
<li>
<button class="w-full rounded-lg py-1 px-2" data-theme={theme}>
{kebabToTitleCase(theme)}
</button>
</li>
))
}
</ul>
</div>
</dialog>
</select-theme>
<style>
button.current-theme {
background-color: color-mix(in srgb, var(--theme-accent) 8%, transparent 92%);
}
</style>
<script>
class SelectTheme extends HTMLElement {
#closeBtn: HTMLButtonElement | null
#dialog: HTMLDialogElement | null
#dialogFrame: HTMLDivElement | null
#openBtn: HTMLButtonElement | null
#controller: AbortController
constructor() {
super()
this.#openBtn = this.querySelector<HTMLButtonElement>('button[data-open-modal]')
this.#closeBtn = this.querySelector<HTMLButtonElement>('button[data-close-modal]')
this.#dialog = this.querySelector<HTMLDialogElement>('dialog')
this.#dialogFrame = this.querySelector('.dialog-frame')
this.#controller = new AbortController()
// Set up events
if (this.#openBtn) {
this.#openBtn.addEventListener('click', this.openModal)
this.#openBtn.disabled = false
} else {
console.warn('Select theme button not found')
}
if (this.#closeBtn) {
this.#closeBtn.addEventListener('click', this.closeModal)
} else {
console.warn('Close button not found')
}
if (this.#dialog) {
this.#dialog.addEventListener('close', () => {
window.removeEventListener('click', this.onWindowClick)
const body = document.querySelector('body')
body?.classList.remove('overflow-hidden')
})
} else {
console.warn('Dialog not found')
}
let themeChangeButtons = this.querySelectorAll('#theme-change-list button')
themeChangeButtons?.forEach((button) => {
button.addEventListener('click', (ev) => {
ev.preventDefault()
let themeId = button.getAttribute('data-theme')
if (themeId) {
document.documentElement.setAttribute('data-theme', themeId)
localStorage.setItem('data-theme', themeId)
this.closeModal()
}
})
})
}
connectedCallback() {
// window events, requires cleanup
window.addEventListener('keydown', this.onWindowKeydown, {
signal: this.#controller.signal,
})
}
disconnectedCallback() {
this.#controller.abort()
}
openModal = (event?: MouseEvent) => {
if (!this.#dialog) {
console.warn('Dialog not found')
return
}
this.highlightCurrentTheme()
const body = document.querySelector('body')
body?.classList.add('overflow-hidden')
this.#dialog.showModal()
event?.stopPropagation()
window.addEventListener('click', this.onWindowClick, {
signal: this.#controller.signal,
})
}
closeModal = () => {
this.#dialog?.close()
}
onWindowClick = (event: MouseEvent) => {
// check if it's a link
const isLink = 'href' in (event.target || {})
// make sure the click is either a link or outside of the dialog
if (
isLink ||
(document.body.contains(event.target as Node) &&
!this.#dialogFrame?.contains(event.target as Node))
) {
this.closeModal()
}
}
onWindowKeydown = (e: KeyboardEvent) => {
if (!this.#dialog) {
console.warn('Dialog not found')
return
}
// check if it's the Control+K or ⌘+K shortcut
if ((e.metaKey === true || e.ctrlKey === true) && e.key === 'k') {
this.#dialog.open ? this.closeModal() : this.openModal()
e.preventDefault()
}
}
highlightCurrentTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme')
if (!currentTheme) {
console.warn('No theme set in data-theme attribute.')
return
}
const themeChangeListItems = this.querySelectorAll('#theme-change-list li')
themeChangeListItems?.forEach((listItem) => {
let button = listItem.querySelector('button')
if (!button) {
console.warn('No button found in theme change list item.')
return
}
if (button.getAttribute('data-theme') === currentTheme) {
button.classList.add('current-theme')
} else {
button.classList.remove('current-theme')
}
})
}
}
customElements.define('select-theme', SelectTheme)
</script>
+25
View File
@@ -0,0 +1,25 @@
---
---
<script is:inline>
;(function loadTheme() {
const pageDefaultTheme = document.documentElement.getAttribute('data-theme')
const pageThemeHash = document.documentElement.getAttribute('data-theme-hash')
if (!pageDefaultTheme || !pageThemeHash) {
throw new Error('Theme attributes are required.')
}
const storedTheme = localStorage.getItem('data-theme')
const storedThemeHash = localStorage.getItem('data-theme-hash')
const themeHashMatches = storedThemeHash === pageThemeHash
if (!storedTheme || !storedThemeHash || !themeHashMatches) {
// Should be the first time loading the website
localStorage.setItem('data-theme', pageDefaultTheme)
localStorage.setItem('data-theme-hash', pageThemeHash)
}
if (themeHashMatches && storedTheme && storedTheme !== pageDefaultTheme) {
// The stored theme is different from the default theme, apply it
document.documentElement.setAttribute('data-theme', storedTheme)
}
})()
</script>
+30
View File
@@ -0,0 +1,30 @@
---
import type { CollectionEntry } from 'astro:content'
import { SeriesGroup } from '~/utils'
interface Props {
posts: CollectionEntry<'posts'>[]
}
const { posts } = Astro.props
const seriesGroup = await SeriesGroup.build(posts)
---
<ul class="flex flex-wrap gap-x-3 gap-y-4 my-5">
{
seriesGroup.sortCollationsMostRecent().map((series) => (
<li>
<a
href={series.url}
class="flex items-start gap-2.5 py-2 pl-4 pr-3 bg-accent/7 rounded-3xl border-transparent hover:border-accent/20 border-1 text-foreground/90"
>
{series.title}
<span class="rounded-full bg-foreground/7 text-foreground/90 px-2 py-1 text-xs font-semibold">
{series.entries.length}
</span>
</a>
</li>
))
}
</ul>
+115
View File
@@ -0,0 +1,115 @@
---
import type { SocialLinks } from '~/types'
import IconGithub from '~/icons/github.svg'
import IconMastodon from '~/icons/mastodon.svg'
import IconTwitter from '~/icons/twitter.svg'
import IconLinkedin from '~/icons/linkedin.svg'
import IconBluesky from '~/icons/bluesky.svg'
import IconEmail from '~/icons/email.svg'
import IconRss from '~/icons/rss.svg'
interface Props {
socialLinks: SocialLinks
}
const { socialLinks } = Astro.props
---
<div class="flex flex-wrap gap-2 items-center justify-center">
{
socialLinks.github && (
<a
href={socialLinks.github}
target="_blank"
rel="noopener noreferrer"
class="group p-2 rounded-xl hover:bg-foreground/5 transition-all duration-300 hover:scale-110"
aria-label="GitHub"
>
<span class="sr-only">GitHub</span>
<IconGithub class="size-6 text-foreground/75 group-hover:text-accent transition-colors" />
</a>
)
}
{
socialLinks.mastodon && (
<a
href={socialLinks.mastodon}
target="_blank"
rel="noopener noreferrer"
class="group p-2 rounded-xl hover:bg-foreground/5 transition-all duration-300 hover:scale-110"
aria-label="Mastodon"
>
<span class="sr-only">Mastodon</span>
<IconMastodon class="size-6 text-foreground/75 group-hover:text-accent transition-colors" />
</a>
)
}
{
socialLinks.bluesky && (
<a
href={socialLinks.bluesky}
target="_blank"
rel="noopener noreferrer"
class="group p-2 rounded-xl hover:bg-foreground/5 transition-all duration-300 hover:scale-110"
aria-label="Bluesky"
>
<span class="sr-only">Bluesky</span>
<IconBluesky class="size-6 text-foreground/75 group-hover:text-accent transition-colors" />
</a>
)
}
{
socialLinks.twitter && (
<a
href={socialLinks.twitter}
target="_blank"
rel="noopener noreferrer"
class="group p-2 rounded-xl hover:bg-foreground/5 transition-all duration-300 hover:scale-110"
aria-label="Twitter"
>
<span class="sr-only">Twitter</span>
<IconTwitter class="size-6 text-foreground/75 group-hover:text-accent transition-colors" />
</a>
)
}
{
socialLinks.linkedin && (
<a
href={socialLinks.linkedin}
target="_blank"
rel="noopener noreferrer"
class="group p-2 rounded-xl hover:bg-foreground/5 transition-all duration-300 hover:scale-110"
aria-label="LinkedIn"
>
<span class="sr-only">LinkedIn</span>
<IconLinkedin class="size-6 text-foreground/75 group-hover:text-accent transition-colors" />
</a>
)
}
{
socialLinks.email && (
<a
href={`mailto:${socialLinks.email}`}
target="_blank"
rel="noopener noreferrer"
class="group p-2 rounded-xl hover:bg-foreground/5 transition-all duration-300 hover:scale-110"
aria-label="Email"
>
<span class="sr-only">Email</span>
<IconEmail class="size-6 text-foreground/75 group-hover:text-accent transition-colors" />
</a>
)
}
{
socialLinks.rss && (
<a
href="/rss.xml"
class="group p-2 rounded-xl hover:bg-foreground/5 transition-all duration-300 hover:scale-110"
aria-label="RSS Feed"
>
<span class="sr-only">RSS</span>
<IconRss class="size-6 text-foreground/75 group-hover:text-accent transition-colors" />
</a>
)
}
</div>
+54
View File
@@ -0,0 +1,54 @@
---
import type { MarkdownHeading } from 'astro'
import TOCHeading from '~/components/TableOfContentsHeading.astro'
interface Props {
headings: MarkdownHeading[]
}
const { headings } = Astro.props
const filteredHeadings = headings.filter(({ depth }) => depth >= 2 && depth <= 3)
---
<details
open
class="toc relative md:mx-2 xl:mx-0 text-foreground/90 text-sm bg-foreground/5 xl:bg-transparent px-8 xl:pr-0 py-6 mt-5 xl:mt-0 xl:w-full rounded-xl border-3 xl:border-none border-accent/10 xl:sticky xl:top-10 xl:basis-[274px] 2xl:basis-[320px] xl:order-2 xl:shrink-0 xl:opacity-90"
>
<summary
class="list-none marker:hidden marker:content-[''] before:content-['>'] before:text-accent before:font-semibold before:absolute before:left-3 cursor-pointer"
>Table of Contents</summary
>
<nav class="w-full text-sm xl:-ml-1">
<ul class="mt-4 flex flex-col max-w-full">
{filteredHeadings.map((heading) => <TOCHeading heading={heading} />)}
</ul>
</nav>
</details>
<script>
const anchors = document.querySelectorAll('h2[id], h3[id]')
const links = document.querySelectorAll('details.toc > nav > ul > li')
if (anchors.length === links.length && anchors.length > 0) {
window.addEventListener('scroll', (_event) => {
let scrollTop = window.scrollY
let highlighted = false
// then iterate backwards, on the first match highlight it and break
for (var i = anchors.length - 1; i >= 0; i--) {
const anchor = anchors[i] as HTMLElement
if (!highlighted && scrollTop > anchor.offsetTop - 75) {
links[i].classList.add('active-heading')
highlighted = true
} else {
links[i].classList.remove('active-heading')
}
}
})
}
</script>
<style>
details[open] summary:before {
transform: rotate(90deg);
}
</style>
@@ -0,0 +1,41 @@
---
import type { MarkdownHeading } from 'astro'
interface Props {
heading: MarkdownHeading
}
const {
heading: { depth, slug, text },
} = Astro.props
---
<li class="flex items-stretch border-foreground/20">
<div class="flex items-stretch">
{
Array.from({ length: depth - 1 }, (_v, _k) => (
<div class="toc-lines flex flex-col items-stretch">
<span class="top-box w-4 xl:w-3 basis-4 border-b-1 border-foreground/20 xl:border-foreground/15" />
<span class="bottom-box w-4 min-h-1/2 xl:w-3 flex-1 border-t-1 border-foreground/20 xl:border-foreground/15" />
</div>
))
}
</div>
<a
class="inline-block line-clamp-2 ml-3 xl:ml-2 py-1 ps-1 hover:text-accent"
href={`#${slug}`}>{text}</a
>
</li>
<style>
.toc-lines:first-child span {
border-left-width: 2px;
}
li:last-child .toc-lines:first-child span.bottom-box {
border-left-width: 0 !important;
}
li.active-heading a {
color: var(--theme-accent);
font-weight: 600;
}
</style>
+22
View File
@@ -0,0 +1,22 @@
---
import type { Collation } from '~/types'
interface Props {
tags: Collation<'posts'>[]
}
const { tags } = Astro.props
---
<div class="flex flex-wrap gap-3 text-sm">
{
tags.map((tag) => (
<a
href={tag.url}
class="py-1 px-3 bg-accent/1 hover:bg-accent/8 border-1 border-accent/20 text-accent/90 rounded-2xl transition-colors"
>
{tag.title}
</a>
))
}
</div>
+29
View File
@@ -0,0 +1,29 @@
---
import type { CollectionEntry } from 'astro:content'
import { TagsGroup } from '~/utils'
interface Props {
posts: CollectionEntry<'posts'>[]
}
const { posts } = Astro.props
const tagsGroup = await TagsGroup.build(posts)
---
<ul class="flex flex-wrap gap-x-3 gap-y-4 my-5">
{
tagsGroup.sortCollationsLargest().map((tag) => (
<li>
<a
href={tag.url}
class="flex items-start gap-2.5 py-2 pl-4 pr-3 bg-accent/7 rounded-3xl border-transparent hover:border-accent/20 border-1 text-foreground/90"
>
{tag.title}
<span class="rounded-full bg-foreground/7 text-foreground/90 px-2 py-1 text-xs font-semibold">
{tag.entries.length}
</span>
</a>
</li>
))
}
</ul>
+66
View File
@@ -0,0 +1,66 @@
import { defineCollection, z } from 'astro:content'
import { glob } from 'astro/loaders'
const postsCollection = defineCollection({
loader: glob({ pattern: ['**/*.md', '**/*.mdx'], base: './src/content/posts' }),
schema: ({ image }) =>
z.object({
title: z.string(),
published: z.coerce.date(),
// updated: z.coerce.date().optional(),
draft: z.boolean().optional().default(false),
description: z.string().optional(),
author: z.string().optional(),
series: z.string().optional(),
tags: z.array(z.string()).optional().default([]),
coverImage: z
.strictObject({
src: image(),
alt: z.string(),
})
.optional(),
toc: z.boolean().optional().default(true),
}),
})
const homeCollection = defineCollection({
loader: glob({ pattern: ['home.md', 'home.mdx'], base: './src/content' }),
schema: ({ image }) =>
z.object({
avatarImage: z
.object({
src: image(),
alt: z.string().optional().default('My avatar'),
})
.optional(),
githubCalendar: z.string().optional(), // GitHub username for calendar
}),
})
const addendumCollection = defineCollection({
loader: glob({ pattern: ['addendum.md', 'addendum.mdx'], base: './src/content' }),
schema: ({ image }) =>
z.object({
avatarImage: z
.object({
src: image(),
alt: z.string().optional().default('My avatar'),
})
.optional(),
}),
})
const legalCollection = defineCollection({
loader: glob({ pattern: ['**/*.md', '**/*.mdx'], base: './src/content/legal' }),
schema: z.object({
title: z.string(),
updated: z.coerce.date(),
}),
})
export const collections = {
posts: postsCollection,
home: homeCollection,
addendum: addendumCollection,
legal: legalCollection,
}
+7
View File
@@ -0,0 +1,7 @@
---
avatarImage:
src: './avatar.jpeg'
alt: 'Michael Kinder'
---
Thanks for reading my blog post! Feel free to check out my other posts or contact me via the social links in the footer.
Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

+8
View File
@@ -0,0 +1,8 @@
---
avatarImage:
src: './avatar.jpeg'
alt: 'Michael Kinder'
githubCalendar: 'foggymtndrifter'
---
I'm a mountain-dwelling Linux specialist and open source contributor. When I'm not coding, you'll find me exploring the Appalachian trails or automating mountain life. Welcome to my digital cabin — grab some coffee and stay awhile.
+48
View File
@@ -0,0 +1,48 @@
---
title: Privacy Policy
updated: 2026-01-30
---
# Privacy Policy
Look, I'm a normie, not a data broker. I built this site to share stuff, not to harvest your personal details. But, for the sake of transparency (and because the internet requires it), here is exactly what is happening with your data.
## 1. The "Who Are You?" Part (Analytics)
I use **Umami** for analytics. Ideally, I'd like to know if anyone is actually reading my blog posts or if I'm just shouting into the void.
- **No Cookies:** Umami doesn't use cookies to track you.
- **No Personal Data:** It doesn't collect your IP address or fingerprint your device.
- **Just Numbers:** It just tells me "Hey, someone visited from the internet." That's it.
## 2. The "Yelling at Me" Part (Comments)
I use GitHub Discussions to power my comments section. When you post a comment, you are interacting directly with the GitHub API. Please refer to GitHub's Privacy Policy for more information on how they handle your data. This means I don't store your comments in a database; GitHub does.
- If you want to know what GitHub does with your data, you'll have to read [their privacy policy](https://docs.github.com/en/site-policy/privacy-policies/github-privacy-statement). I assume it involves storing your comment.
## 3. The "Free Coffee" Part (Donations)
If you are generous enough to buy me a coffee via the "sudo buy-coffee" modal:
- **Stripe** handles all the heavy lifting.
- I **never** see your credit card number. It goes straight to Stripe's secure vault.
- Stripe might collect your email or name to send you a receipt, but that's between you and them.
- You can read Stripe's privacy policy [here](https://stripe.com/privacy) if you enjoy reading legal documents.
## 4. Cookies (The Digital Kind)
I prefer the oatmeal raisin variety. This site itself is pretty much cookie-free.
- **However:** Third-party services like **Stripe** (for payments) and **GitHub** (for comments) might verify you are human (or logged in) using cookies.
- I have no control over those, but they are generally essential for security and functionality.
## 5. Your Rights
You have the right to remain silent... wait, wrong script.
You have the right to access, correct, or delete your data. Since I generally don't *have* your data (it's with Stripe or GitHub), you'll likely need to contact them directly. But if you think I have something of yours, feel free to reach out.
## 6. Changes
I might update this page if I add new features. I'll change the date below if I do.
## 7. Contact
If you have questions, [email me](mailto:michael@foggymtndrifter.com) or send a carrier pigeon.
+44
View File
@@ -0,0 +1,44 @@
---
title: Terms of Use
updated: 2026-01-28
---
# Terms of Use
Welcome to **foggymtndrifter.com**. By hanging out here, you agree to these terms. If you don't agree, you can simply close the tab (but I'll be sad to see you go).
## 1. The Gist
This is a personal blog. I write code, I write opinions, and sometimes I write bugs. Use the site as intended, don't try to hack it, and we'll get along fine.
## 2. Comments (Be Nice)
Comments are hosted via **GitHub Discussions**.
- **Don't be a jerk.** No hate speech, harassment, or spam.
- **I have the ban hammer.** I reserve the right to hide or delete comments that violate common decency (or just really annoy me).
- **Your words are yours.** You own your comments, but by posting them here, you let me display them.
## 3. Money Stuff (Donations)
If you use the donation modal:
- **Thank you!** You are awesome.
- **No Refunds.** Please treat donations like dropping a tip in a jar. Once it's in, it's coffee money. I can't easily process refunds, so please double-check the amount.
- **Security.** Payments are processed by Stripe. I don't touch your financial data.
## 4. Code & Content
- **My Stuff:** The text and images are mine unless noted otherwise. Please don't scrape the whole site and repost it as your own.
- **Code Snippets:** Feel free to use code snippets from my blog posts in your own projects. That's why they are there. A link back is appreciated but not required.
- **No Warranty:** If you copy-paste code from here and it crashes your production server, that is unfortunate, but it is not my fault. **Always** review code before running it.
## 5. Third Parties
I link to other sites (like GitHub, Stripe, or random cool blogs). I'm not responsible for what happens on those sites.
## 6. Liability (The "Don't Sue Me" Clause)
This site is provided "as is." I try my best to keep it running and accurate, but I make no promises. I am not liable for any damages (digital, emotional, or otherwise) resulting from your use of this site.
## 7. Changes
I can change these terms whenever. If you keep using the site, you accept the new terms.
@@ -0,0 +1,165 @@
---
title: "Building a Custom Comment System with GitHub Discussions"
published: 2026-01-29
draft: false
description: "How I replaced Giscus with a custom comment system powered by GitHub Discussions, complete with guest comments, OAuth, and spam protection."
tags: ['Astro', 'Svelte', 'GitHub API', 'Web Development']
---
I recently decided to replace Giscus on my blog with a custom-built comment system. While Giscus is fantastic, I wanted more control over the user experience and the ability to support guest comments without requiring GitHub authentication. Here's how I built it.
## The Goal
I wanted a comment system that:
- Uses GitHub Discussions as the backend (free, reliable, and I already use GitHub)
- Supports both authenticated GitHub users and anonymous guests
- Has a clean UI that matches my site's aesthetic
- Includes basic spam protection (honeypot + CAPTCHA)
- Sorts comments newest-first
## The Architecture
The system has three main components:
1. **Backend API routes** (Astro SSR endpoints)
2. **Frontend component** (Svelte for reactivity)
3. **OAuth flow** (GitHub authentication)
### Backend: API Routes
I created several API routes in `src/pages/api/`:
#### `/api/comments/[slug].ts`
This is the workhorse. It handles both fetching and posting comments.
**GET**: Fetches all comments for a post by searching GitHub Discussions for a discussion matching the post's pathname.
```typescript
const searchQuery = `repo:${siteConfig.comments.repo} in:title ${term}`
const result = await octokit.graphql(query, { term: searchQuery })
```
The key insight here: GitHub's search API is powerful. By searching for discussions with the post's pathname in the title, I can reliably find the right discussion.
**POST**: Creates a new comment. This is where it gets interesting:
- If the user is authenticated (has a GitHub token cookie), post as them
- If not, post as a bot account and append `_(Posted by DisplayName)_` to the comment body
- The GET endpoint strips this attribution and uses it to display the guest's name
#### OAuth Routes
Three simple routes handle GitHub authentication:
- `/api/auth/signin` - Redirects to GitHub OAuth
- `/api/auth/callback` - Exchanges code for token, stores in HTTP-only cookie
- `/api/auth/signout` - Clears the token cookie
### Frontend: Svelte Component
I chose Svelte for the comment UI because it's lightweight and has excellent reactivity. The component handles:
- Displaying comments with proper nesting (replies)
- A collapsible comment form
- Switching between "Post as Guest" and "Sign in with GitHub" modes
- Client-side CAPTCHA validation for guests
```svelte
{#if authMode === 'guest' && !user}
<div>
<label for="captcha">Human Check: What is {num1} + {num2}?</label>
<input id="captcha" type="number" bind:value={captchaAnswer} required />
</div>
{/if}
```
## Guest Comments: The Attribution Trick
The clever part about guest comments is how they're stored. Since GitHub Discussions doesn't natively support "guest" authors, I:
1. Post the comment using a bot account
2. Append the guest's display name in a specific format: `_(Posted by Jane Doe)_`
3. On fetch, parse this attribution and transform the comment object
4. Strip the attribution from the HTML before displaying
This means the comment lives in GitHub Discussions, but appears to be from the guest user in my UI.
## Spam Protection
I implemented two layers of protection:
### Honeypot
A hidden field that bots might fill but humans won't:
```svelte
<input type="text" name="website_honey" class="hidden" bind:value={honeyPot} />
```
If this field has a value, the submission is rejected.
### Math CAPTCHA
For guest comments only, a simple math question:
```typescript
if (!userToken) {
if (parseInt(num1) + parseInt(num2) !== parseInt(answer)) {
return new Response('Incorrect math answer', { status: 400 })
}
}
```
It's basic, but effective against simple bots without annoying users with complex CAPTCHAs.
## UI Polish
I spent time making the UI feel native to my site:
- Generic avatars for guests (using DiceBear's initials API)
- Changed "OWNER" badge to "ADMIN"
- Newest-first sorting (reversed the GitHub API response)
- Collapsible form (using native `<details>` element)
- Matched the styling to my "Buy me a coffee" modal
## Configuration
The system is configured in `site.config.ts`:
```typescript
comments: {
repo: 'FoggyMtnDrifter/website',
repoId: 'R_kgDORBPuRw',
category: 'General',
categoryId: 'DIC_kwDORBPuR84C1bYd',
}
```
You'll also need environment variables:
```bash
GITHUB_CLIENT_ID=your_oauth_app_client_id
GITHUB_CLIENT_SECRET=your_oauth_app_secret
GITHUB_PERSONAL_ACCESS_TOKEN=your_bot_token
```
## Lessons Learned
1. **GitHub's GraphQL API is powerful** - The search query approach works better than trying to filter discussions directly
2. **HTTP-only cookies are your friend** - Storing OAuth tokens securely is critical
3. **Simple spam protection works** - You don't need reCAPTCHA for a personal blog
4. **Native HTML is underrated** - Using `<details>` for the collapsible form meant zero JavaScript for that feature
## The Result
I now have a comment system that:
- Costs nothing (uses GitHub's free tier)
- Supports both authenticated and guest users
- Matches my site's aesthetic perfectly
- Has basic spam protection
- Gives me full control over the UX
The best part? All comments are stored in GitHub Discussions, so they're backed up, searchable, and I can manage them using GitHub's excellent moderation tools.
If you're building an Astro site and want more control than Giscus provides, this approach is definitely worth considering. The code is all on my [GitHub repo](https://github.com/FoggyMtnDrifter/website) if you want to dig deeper.
@@ -0,0 +1,118 @@
---
title: "Integrating Gitea & Vercel"
published: 2024-06-18
draft: false
description: 'I share my experience integrating the open source tool Gitea and the hosting platform Vercel for streamlined code management and deployment.'
tags: ['Gitea', 'Vercel']
---
I'm a strong supporter of open source software and find that the tools I choose strongly influence my development workflow. For managing my code, I use [Gitea](https://gitea.io/). It's a fantastic, open source version control platform that's lightweight and offers the features and security I need. One of the aspects I love is [Gitea Actions](https://docs.gitea.io/en-us/actions/), which makes it easy to streamline my deployment process to [Vercel](https://vercel.com).
## Why Gitea?
Gitea excels as a self-hosted, open source version control platform. If you like having flexibility and control over your development setup, it's a compelling alternative to larger, corporate-owned code hosting solutions. With Gitea Actions (which are compatible with GitHub Actions), I can tap into the rich ecosystem of Actions from the broader development community without sacrificing the benefits of an independent, open source platform.
## Streamlining Deployment with Vercel
Let's talk about how I use Gitea Actions for deployment. Here's my `preview.yaml` workflow that enables streamlined feedback cycles. Whenever I open a pull request to the main branch, this workflow triggers a preview deployment on Vercel. This lets me test and gather feedback before merging changes:
```yaml
name: Vercel Preview Deployment
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
on:
pull_request:
branches:
- main
jobs:
Deploy-Preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v4
with:
node-version: ">=18.14.1"
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel Environment Information
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy Project Artifacts to Vercel
id: deploy
run: |
echo "::group::Deploying"
DEPLOY_OUTPUT=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} 2>&1)
echo "$DEPLOY_OUTPUT"
echo "::endgroup::"
PREVIEW_URL=$(echo "$DEPLOY_OUTPUT" | grep -o 'Preview: https://[1]*' | awk '{print $2}')
echo "PREVIEW_URL=$PREVIEW_URL" >> $GITHUB_ENV
echo "::set-output name=preview_url::$PREVIEW_URL"
if [[ -z "$PREVIEW_URL" ]]; then exit 1; fi
continue-on-error: false
- name: Comment on PR on Success
if: ${{ success() && env.PREVIEW_URL }}
uses: actions/github-script@v5
with:
script: |
const previewUrl = '${{ env.PREVIEW_URL }}';
github.rest.issues.createComment({
issue_number: context.payload.pull_request.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Preview deployment successful.\n\nView Preview: ${previewUrl}`
});
- name: Comment on PR on Error
if: ${{ failure() }}
uses: actions/github-script@v5
with:
script: |
github.rest.issues.createComment({
issue_number: context.payload.pull_request.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Deployment encountered an issue. Please refer to the workflow logs for more information.'
});
```
## Production Deployments
Once the preview is approved, my `production.yaml` workflow deploys changes to my live site. It's very similar to the preview workflow, but tailored for the production environment:
```yaml
name: Vercel Production Deployment
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
on:
push:
branches:
- main
jobs:
Deploy-Production:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v4
with:
node-version: ">=18.14.1"
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel Environment Information
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy Project Artifacts to Vercel
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
```
## Open Source in Practice
Those workflows show how I use open source tools to maintain a flexible and efficient approach to development. Gitea's adaptability and the seamless integration with GitHub Actions demonstrates the power of open source in creating custom workflows without sacrificing efficiency.
## My Thoughts on This Approach
Using Gitea Actions has been a great experience for streamlining my deployment process. It highlights how open source development enables customization and the ability to tap into community resources. If you're seeking a flexible development environment that emphasizes open source principles, Gitea and Vercel make a powerful combination.
@@ -0,0 +1,68 @@
---
title: "Introducing ClearProxy"
published: 2025-05-22
draft: false
description: "ClearProxy is a modern web-based management interface for Caddy Server that simplifies reverse proxy configuration through an intuitive UI while maintaining Caddy's core features like automatic HTTPS and security defaults."
tags: ['Caddy', 'ClearProxy']
---
As a long-time user of [Caddy Server](https://caddyserver.com/), I've always appreciated its simplicity, automatic HTTPS capabilities, and robust performance. However, one pain point kept nagging at me: maintaining Caddyfile configurations across multiple servers and projects. That's what led me to create ClearProxy, a modern web-based management interface for Caddy that focuses on making reverse proxy configuration as straightforward as possible.
## The Journey
The idea for ClearProxy was born out of a simple desire: I wanted the power of Caddy without the hassle of manually editing configuration files. While Caddy's Caddyfile syntax is clean and intuitive, managing multiple proxy configurations across different environments can become tedious. I wanted a solution that would:
1. Provide a beautiful, intuitive interface for managing proxy hosts
2. Maintain Caddy's core philosophy of simplicity and security
3. Offer advanced features for power users without overwhelming beginners
## Technical Implementation
I built ClearProxy using modern web technologies that prioritize performance and developer experience:
- **SvelteKit** for the frontend, offering a responsive and snappy user interface
- **SQLite** for reliable data storage without the complexity of a separate database server
- **Docker** for easy deployment and consistent environments
- **Caddy's Admin API** for seamless integration with the Caddy server
The architecture is deliberately simple: two containers working in harmony - one running the ClearProxy application and another running Caddy server. This setup ensures that users get all the benefits of Caddy (automatic HTTPS, modern security defaults) while enjoying a user-friendly management interface.
## Key Features
Some of the features I'm most proud of include:
- **Intuitive Proxy Management:** Add and configure proxy hosts with just a few clicks
- **Automatic HTTPS:** Leveraging Caddy's built-in ACME client for SSL/TLS certificates
- **Basic Authentication:** Easily secure proxied hosts when needed
- **Advanced Configuration:** Raw Caddyfile syntax support for power users
- **Access Logging:** Built-in monitoring capabilities
## The Rewards
Building ClearProxy has been incredibly rewarding for a couple reasons:
1. **Learning Experiences:** The project pushed me to dive deep into Caddy's internals, modern web development practices, and container orchestration. Every challenge was an opportunity to learn something new.
2. **Open Source Collaboration:** The project is open source, and I'm hoping and looking forward to collaborating with other developers to make it better.
## Looking Forward
ClearProxy is more than just a personal tool - it's becoming a project that makes Caddy more accesible to everyone. Future plans include:
- Enhanced monitoring and analytics
- Support for more advanced Caddy features
- Improved documentation and tutorials
- Community-requested features and improvements
## Try It Yourself
If you're using Caddy and want to simplify your proxy management, give ClearProxy a try. The project is [available on GitHub](https://github.com/FoggyMtnDrifter/ClearProxy), and getting started is as simple as running a few Docker commands.
```bash
mkdir clearproxy && cd clearproxy
curl -L https://raw.githubusercontent.com/foggymtndrifter/clearproxy/main/docker-compose.yml -o docker-compose.yml
docker compose up -d
```
## Conclusion
Building ClearProxy has been a journey of solving a personal pain point that turned into something much bigger. It's a testament to the power of open source and the satisfaction that comes from creating tools that make developers' lives easier. If you're using Caddy or looking for a more modern reverse proxy solution, I encourage you to give ClearProxy a try and join our community.
@@ -0,0 +1,33 @@
---
title: "Rocky Linux: A User's Guide to Contributing"
published: 2024-04-14
draft: false
description: "Learn how to go from open source enthusiast to active contributor within the welcoming Rocky Linux community."
tags: ['Rocky Linux', 'Open Source']
---
I've always been passionate about open source software. The idea of collaborative communities building powerful tools freely available to everyone resonated deeply with me. Yet, for a long time, I was just a user, a beneficiary. I'd never taken the plunge into actually *contributing* to an open source project.
That all changed when the CentOS landscape shifted. Like many, I was caught off guard by the news around CentOS 8. Reading Greg Kurtzer's announcement of Rocky Linux on the CentOS blog, something clicked. This was my chance to put my belief in open source into action.
With a mix of excitement and apprehension, I joined the Rocky Linux Slack (which later transitioned to Mattermost). I had zero open source contribution experience, yet I found a welcoming community eager to bring enthusiastic people on board. I started by talking to the then Web Team Lead, and soon I was making small contributions to the Rocky Linux website.
My involvement grew quickly. As my contributions increased, so did my sense of ownership in the project. Before I knew it, I was leading the Web Team, overseeing the development and maintenance of the main Rocky Linux site. It was incredibly rewarding!
I've since scaled back my involvement in that specific role, but remain integral in shaping the project's visual identity as the Design Team Lead. The thrill of helping a crucial open source project remains the same.
## How You Can Make a Difference
My story isn't about being a technical genius. It's about the power of the Rocky Linux community, a community that throws its doors wide open and says, "Come build with us!" If you're intrigued by this project, here's your roadmap for getting involved:
1. **Join the Mattermost Chat:** The heart of the Rocky Linux community beats on [Mattermost](https://chat.rockylinux.org). Dive right in!
2. **Explore Your Interests:** Find channels that align with your skills or areas you want to learn about. Maybe you like to write; documentation is always needed. Love to design? Join me on the Design Team. If you're a developer, well, the possibilities are endless.
3. **Let Your Voice Be Heard:** Don't be shy! Introduce yourself within the channels, let people know you're interested in helping out. You'll be amazed at how quickly you'll be guided towards ways to contribute.
## Open Doors, Open Community
The core of Rocky Linux is its inclusivity. Whether you're a seasoned expert, a student wanting to learn, or someone who just wants to give back to the open source world, there's a place for you.
Don't think you have to become a team lead like I did to make a difference. Every pull request reviewed, every documentation page written, every design asset created; it all matters. We're not just building an operating system; we're building a vibrant community.
What are you waiting for? Join us! You might be surprised by how much you can achieve and who you might become.
@@ -0,0 +1,75 @@
---
title: "Installing Virtual Machine Manager on Void Linux"
published: 2024-04-22
draft: false
description: "This step-by-step tutorial shows you how to install and use Virtual Machine Manager on Void Linux."
tags: ['Virtual Machine Manager', 'Void Linux']
---
If you're a fellow Void Linux enthusiast like me, you know the thrill of a lean, customizable system. But sometimes, you might want to run other operating systems within your streamlined Void environment. That's where virtual machines (VMs) come to the rescue, and Virtual Machine Manager makes it super easy to set them up.
In this guide, I'll walk you through the steps I took to get this working.
## Step 1: Installing the Essentials
```bash
sudo xbps-install libvirt virt-manager qemu polkit
```
This gets us all the pieces we need; `libvirt` for virtualization magic, `virt-manager` for a friendly interface, `qemu` as our trusty emulator, and `polkit` for handling permissions.
## Step 2: Getting the Right Permissions
We need to make sure our regular user account can play with virtual machines. Let's add ourselves to the libvirt and kvm groups:
```bash
sudo usermod -a -G libvirt,kvm your_username
```
(Remember to replace `your_username` with your actual username.)
## Step 3: A Quick Log Out and Back In
Just to be sure the group changes stick, log out of your account and log back in.
## Step 4: A Tiny Bit of Configuration
Let's setup a config file for `libvirt` so it knows what's up:
```bash
mkdir ~/.config/libvirt && sudo cp -rv /etc/libvirt/libvirt.conf ~/.config/libvirt/ && sudo chown your_username:your_user_group ~/.config/libvirt/libvirt.conf
```
## Step 5: Tweaking libvirt Settings
Open `~/.config/libvirt/libvirt.conf` in your favorite text editor and find the line that says `uri_default`. Change it to:
```bash
uri_default = "qemu:///system"
```
## Step 6: QEMU Permissions
Edit `/etc/libvirt/qemu.conf`, setting the user and group to match your username and libvirt respectively. This lets you manage the VMs you create.
## Step 7: Starting the Services
Void Linux uses `runit` for services. Let's enable the ones we need:
```bash
sudo ln -s /etc/sv/dbus /var/service/
sudo ln -s /etc/sv/polkitd /var/service/
sudo ln -s /etc/sv/libvirtd /var/service/
sudo ln -s /etc/sv/virtlockd /var/service/
sudo ln -s /etc/sv/virtlogd /var/service/
```
## Step 8: Launch Time!
That's it! Go ahead, launch Virtual Machine Manager, and get ready to spin up new virtual machines!
---
## Bonus Tip: Pump Up the Graphics
Want smoother graphics in your VMs? Edit a VM's settings, go to "Video", select "Virtio", and check the "3D Acceleration" box.
+5
View File
@@ -0,0 +1,5 @@
declare module '@pagefind/default-ui' {
declare class PagefindUI {
constructor(arg: unknown)
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6.335 5.144C4.681 3.945 2 3.017 2 5.97c0 .59.35 4.953.556 5.661C3.269 14.094 5.686 14.381 8 14c-4.045.665-4.889 3.208-2.667 5.41C6.363 20.428 7.246 21 8 21c2 0 3.134-2.769 3.5-3.5q.5-1 .5-1.5q0 .5.5 1.5c.366.731 1.5 3.5 3.5 3.5c.754 0 1.637-.571 2.667-1.59C20.889 17.207 20.045 14.664 16 14c2.314.38 4.73.094 5.444-2.369c.206-.708.556-5.072.556-5.661c0-2.953-2.68-2.025-4.335-.826C15.372 6.806 12.905 10.192 12 12c-.905-1.808-3.372-5.194-5.665-6.856"/></svg>

After

Width:  |  Height:  |  Size: 649 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up-icon lucide-chevron-up"><path d="m18 15-6-6-6 6"/></svg>

After

Width:  |  Height:  |  Size: 269 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-left-icon lucide-chevrons-left"><path d="m11 17-5-5 5-5"/><path d="m18 17-5-5 5-5"/></svg>

After

Width:  |  Height:  |  Size: 301 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-right-icon lucide-chevrons-right"><path d="m6 17 5-5-5-5"/><path d="m13 17 5-5-5-5"/></svg>

After

Width:  |  Height:  |  Size: 302 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>

After

Width:  |  Height:  |  Size: 312 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mail-icon lucide-mail"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/><rect x="2" y="4" width="20" height="16" rx="2"/></svg>

After

Width:  |  Height:  |  Size: 331 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-github-icon lucide-github"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></svg>

After

Width:  |  Height:  |  Size: 528 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-linkedin-icon lucide-linkedin"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect width="4" height="12" x="2" y="9"/><circle cx="4" cy="4" r="2"/></svg>

After

Width:  |  Height:  |  Size: 399 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15.5 21.5c-10.5 2.5 -12.5 -2.5 -12.5 -8.5v-3c0 -6 2.5 -7 7 -7h4c4.5 0 7 1.5 7 5.5v4c0 6.5 -10 4 -13.5 4c-1 0 -1.5 7 8 5Z"/><path d="M7 13.5l0 -5.5c0 0 0.5 -2 2.5 -2c2 0 2.5 2 2.5 2l0 2.5l0 -2.5c0 0 0.5 -2 2.5 -2c2 0 2.5 2 2.5 2l0 5.5"/></g></svg>

After

Width:  |  Height:  |  Size: 440 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-moon-icon lucide-moon"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>

After

Width:  |  Height:  |  Size: 277 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-palette-icon lucide-palette"><path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 580 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rss-icon lucide-rss"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>

After

Width:  |  Height:  |  Size: 324 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>

After

Width:  |  Height:  |  Size: 294 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sun-moon-icon lucide-sun-moon"><path d="M12 2v2"/><path d="M13 8.129A4 4 0 0 1 15.873 11"/><path d="m19 5-1.256 1.256"/><path d="M20 12h2"/><path d="M9 8a5 5 0 1 0 7 7 7 7 0 1 1-7-7"/></svg>

After

Width:  |  Height:  |  Size: 392 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sun-icon lucide-sun"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>

After

Width:  |  Height:  |  Size: 470 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-twitter-icon lucide-twitter"><path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z"/></svg>

After

Width:  |  Height:  |  Size: 384 B

+179
View File
@@ -0,0 +1,179 @@
---
import '~/styles/global.css'
import '@fontsource/varela'
import Header from '~/components/Header.astro'
import Footer from '~/components/Footer.astro'
import LightDarkAutoThemeLoader from '~/components/LightDarkAutoThemeLoader.astro'
import SelectThemeLoader from '~/components/SelectThemeLoader.astro'
import siteConfig from '~/site.config'
import { pick, resolveThemeColorStyles } from '~/utils'
import crypto from 'crypto'
import BuyMeACoffeeModal from '~/components/BuyMeACoffeeModal.svelte'
import CookieConsent from '~/components/CookieConsent.astro'
interface Props {
title?: string
description?: string
tags?: string[]
author?: string
}
const { title, description, tags, author } = Astro.props
const pageUrl = new URL(Astro.url.pathname, Astro.site).href.replace(/\/$/, '') // Remove trailing slash for consistency
const pageType = Astro.url.pathname.startsWith('/posts') ? 'article' : 'website'
const pageTitle = title ? `${title} - ${siteConfig.title}` : siteConfig.title
const pageDescription = description || siteConfig.description
const pageAuthor = author || siteConfig.author
const pageImage =
pageType === 'article'
? Astro.url.origin +
Astro.url.pathname.replace(/\/posts\//, '/social-cards/') +
'.png'
: `${Astro.url.origin}/social-cards/__default.png`
const pageKeywords = [
...new Set(siteConfig.tags.concat(tags || []).map((word) => word.toLowerCase())),
].join(', ')
const baseCssVars: { [key: string]: string } = {
'theme-font': siteConfig.font,
'ec-frm-frameBoxShdCssVal': 'none',
'ec-frm-edTabBrdRad': '0',
'ec-frm-edTabBarBrdCol':
'color-mix(in srgb, var(--theme-foreground), 10%, transparent)',
'ec-brdCol': 'color-mix(in srgb, var(--theme-foreground), 10%, transparent)',
}
let themeMode = siteConfig.themes.mode
if (siteConfig.themes.include.length < 1) {
throw new Error('No themes defined in site.config. Please add at least one theme.')
}
if (themeMode === 'light-dark-auto' && siteConfig.themes.include.length < 2) {
console.warn(
'Theme mode "dark-light-auto" requires at least two themes. Defaulting to "single".',
)
themeMode = 'single'
}
let defaultTheme = siteConfig.themes.default || siteConfig.themes.include[0]
let includedThemes = siteConfig.themes.include as string[]
const themeNotIncluded = !includedThemes.includes(defaultTheme)
if (
(themeMode !== 'light-dark-auto' && themeNotIncluded) ||
(themeMode === 'light-dark-auto' && defaultTheme !== 'auto' && themeNotIncluded)
) {
console.warn(
`Default theme "${defaultTheme}" not found in themes. Using first theme: "${siteConfig.themes.include[0]}".`,
)
defaultTheme = siteConfig.themes.include[0]
}
let lightTheme = themeMode === 'light-dark-auto' ? includedThemes[0] : undefined
let darkTheme = themeMode === 'light-dark-auto' ? includedThemes[1] : undefined
// Generate a hash to use for cache busting the theme settings in localStorage.
const themeHash = crypto
.createHash('md5')
.update(themeMode + defaultTheme + includedThemes.join(''))
.digest('hex')
.slice(0, 8) // Truncate to the first 8 characters
const resolvedThemes = await resolveThemeColorStyles(
siteConfig.themes.include,
siteConfig.themes.overrides,
)
let cssLines: string[] = []
for (const [themeId, themeStyles] of Object.entries(resolvedThemes)) {
const relevantStyles = pick(themeStyles, [
'foreground',
'background',
'accent',
'heading1',
'heading2',
'heading3',
'heading4',
'heading5',
'heading6',
'list',
'italic',
'link',
'separator',
'note',
'tip',
'important',
'caution',
'warning',
'blue',
'green',
'red',
'yellow',
'magenta',
'cyan',
])
cssLines.push(`:root[data-theme="${themeId}"] {`)
for (const [key, value] of Object.entries(relevantStyles)) {
cssLines.push(`--theme-${key}: ${value};`)
}
cssLines.push(`}`)
}
let generatedCss: string = cssLines.join('\n')
---
<!doctype html>
<html
lang="en"
data-theme={defaultTheme}
data-dark-theme={darkTheme}
data-light-theme={lightTheme}
data-theme-mode={themeMode}
data-theme-hash={themeHash}
>
<head>
<meta charset="UTF-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<meta name="generator" content={Astro.generator} />
<meta name="title" content={pageTitle} />
<meta name="description" content={pageDescription} />
<meta name="author" content={pageAuthor} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={pageDescription} />
<meta property="og:url" content={pageUrl} />
<meta property="og:type" content={pageType} />
{pageImage && <meta property="og:image" content={pageImage} />}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={pageDescription} />
{pageImage && <meta name="twitter:image" content={pageImage} />}
<meta name="keywords" content={pageKeywords} />
<link rel="canonical" href={pageUrl} />
<link rel="sitemap" href="/sitemap-index.xml" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link
rel="alternate"
type="application/rss+xml"
title={siteConfig.title}
href={new URL('rss.xml', Astro.site)}
/>
<title>{pageTitle}</title>
<style is:global define:vars={baseCssVars}></style>
<style is:inline set:html={generatedCss}></style>
{themeMode === 'light-dark-auto' && <LightDarkAutoThemeLoader />}
{themeMode === 'select' && <SelectThemeLoader />}
<script
defer
src="https://analytics.foggymtndrifter.com/script.js"
data-website-id="c2e776cd-5a10-41d0-a5ef-cefc8f3d8499"></script>
</head>
<body class="w-full h-full m-0 bg-background text-foreground">
<div
class="flex flex-col max-w-3xl min-h-screen border-accent/10 m-auto p-3 sm:py-5 sm:px-6 md:py-10"
>
<Header />
<main class="flex flex-col py-1">
<slot />
</main>
<Footer />
</div>
<BuyMeACoffeeModal client:only="svelte" />
<CookieConsent />
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
---
import Layout from '~/layouts/Layout.astro'
import type { MarkdownLayoutProps } from 'astro'
type Props = MarkdownLayoutProps<{
// Define frontmatter props here
title: string
description?: string
}>
// Now, `frontmatter`, `url`, and other Markdown layout properties
// are accessible with type safety
const { frontmatter } = Astro.props
---
<Layout title={frontmatter.title} description={frontmatter.description}>
<div class="max-w-full py-7.5">
<h1 class="md:mx-2 mb-3 text-[1.75rem] text-heading1 font-semibold">
# {frontmatter.title}
</h1>
<div class="mb-5 prose">
<slot />
</div>
</div>
</Layout>
+7
View File
@@ -0,0 +1,7 @@
---
import Layout from '~/layouts/Layout.astro'
---
<Layout>
<h1 class="inline-block m-auto p-4 text-accent text-4xl">404</h1>
</Layout>
+368
View File
@@ -0,0 +1,368 @@
---
import Layout from '~/layouts/Layout.astro'
import { Image } from 'astro:assets'
import myAvatar from '../content/avatar.jpeg'
import siteConfig from '~/site.config'
import IconEmail from '~/icons/email.svg'
import BlockHeader from '~/components/BlockHeader.astro'
const title = 'About Michael Kinder'
const description = 'Professional experience and skills of Michael Kinder.'
---
<Layout title={title} description={description}>
<div class="max-w-3xl mx-auto py-8">
<!-- Header Section -->
<section
class="mb-12 flex flex-col sm:flex-row items-center sm:items-center gap-12 border-b-2 border-accent/20 pb-8"
>
<div
class="shrink-0 relative group w-32 h-32 flex items-center justify-center sm:ml-6"
>
<Image
src={myAvatar}
alt="Michael Kinder"
class="rounded-full w-32 h-32 border-4 border-accent object-cover shadow-lg relative z-10"
/>
{
siteConfig.hireMe && (
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-48 h-48 pointer-events-none opacity-90 group-hover:opacity-100 transition-opacity">
<svg
viewBox="0 0 200 200"
class="w-full h-full text-accent animate-spin-slow"
>
<defs>
<path
id="circlePath"
d="M 100, 100 m -75, 0 a 75,75 0 1,1 150,0 a 75,75 0 1,1 -150,0"
/>
</defs>
<text
fill="currentColor"
font-size="13.5"
font-weight="bold"
letter-spacing="2"
class="uppercase"
>
<textPath
href="#circlePath"
startOffset="0%"
textLength="471"
lengthAdjust="spacing"
>
Available for Hire&nbsp;•&nbsp;Available for Hire&nbsp;•&nbsp;
</textPath>
</text>
</svg>
</div>
)
}
</div>
<div class="text-center sm:text-left">
<h1 class="text-4xl font-bold text-heading1 mb-2">Michael Kinder</h1>
<p class="text-xl text-heading2 italic mb-4">Technical Leader</p>
<div
class="flex flex-wrap justify-center sm:justify-start gap-4 text-sm text-foreground/80"
>
<span class="flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-map-pin"
><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"></path><circle
cx="12"
cy="10"
r="3"></circle></svg
>
Pikeville, Kentucky
</span>
{
siteConfig.socialLinks.email && (
<span class="flex items-center gap-1">
<IconEmail class="size-4" />
<a href={`mailto:${siteConfig.socialLinks.email}`} class="disabled-link">
{siteConfig.socialLinks.email.replace('mailto:', '')}
</a>
</span>
)
}
</div>
</div>
</section>
<!-- Summary Section -->
<section class="mb-12">
<BlockHeader>Summary</BlockHeader>
<p class="leading-relaxed text-foreground/90 my-5">
Located in Pikeville, KY, I am a Technical Support professional with hands-on
experience providing remote end-user support, troubleshooting hardware and
software issues, and leading technical support teams in fast-paced hosting and
services environments.
</p>
</section>
<!-- Experience Section -->
<section class="mb-12">
<BlockHeader>Experience</BlockHeader>
<div class="relative border-l-2 border-accent/20 ml-3 pl-8 pb-8 space-y-8 my-5">
{/* Experience Item 1 */}
<div class="relative">
<span
class="absolute -left-[43px] top-1 h-5 w-5 rounded-full border-4 border-background bg-accent"
></span>
<h3 class="text-xl font-bold text-heading3">Technical Support Team Lead</h3>
<div class="flex items-center justify-between mb-2">
<span class="text-accent font-semibold">hosting.com</span>
<span class="text-sm text-foreground/60 bg-foreground/5 px-2 py-0.5 rounded"
>Mar 2023 - Dec 2025</span
>
</div>
<ul
class="list-disc list-outside ml-5 space-y-1 text-foreground/80 marker:text-accent"
>
<li>
Led a team of 32 employees, providing guidance and support to ensure
successful completion of projects.
</li>
<li>
Implemented effective communication strategies to foster collaboration and
improve team productivity.
</li>
<li>
Developed and implemented training programs for new team members, resulting
in reduced onboarding time.
</li>
<li>
Monitored team performance and provided regular feedback to drive continous
improvement.
</li>
<li>
Collaborated with cross-functional teams to identify process improvements
and implement best practices.
</li>
</ul>
</div>
{/* Experience Item 2 (Inferred/Generic based on "career") */}
<div class="relative">
<span
class="absolute -left-[43px] top-1 h-5 w-5 rounded-full border-4 border-background bg-background"
>
<span class="block h-full w-full rounded-full bg-accent/50"></span>
</span>
<h3 class="text-xl font-bold text-heading3">Technical Support Specialist</h3>
<div class="flex items-center justify-between mb-2">
<span class="text-accent font-semibold">InMotion Hosting</span>
<span class="text-sm text-foreground/60 bg-foreground/5 px-2 py-0.5 rounded"
>Feb 2022 - Feb 2023</span
>
</div>
<ul
class="list-disc list-outside ml-5 space-y-1 text-foreground/80 marker:text-accent"
>
<li>
Provided technical support to end-users and troubleshooting hardware and
software issues.
</li>
<li>
Resolved 95% of customer inquiries on the first call and exceeding
departmental targets.
</li>
<li>
Collaborated with cross-functional teams to identify and resolve complex
technical problems.
</li>
<li>
Documented all support interactions accurately and thoroughly in the
ticketing system.
</li>
<li>
Performed remote troubleshooting through diagnostic techniques and pertinent
questions.
</li>
</ul>
</div>
<div class="relative">
<span
class="absolute -left-[43px] top-1 h-5 w-5 rounded-full border-4 border-background bg-background"
>
<span class="block h-full w-full rounded-full bg-accent/50"></span>
</span>
<h3 class="text-xl font-bold text-heading3">Data Services Expert</h3>
<div class="flex items-center justify-between mb-2">
<span class="text-accent font-semibold">Intuit</span>
<span class="text-sm text-foreground/60 bg-foreground/5 px-2 py-0.5 rounded"
>Sep 2018 - May 2021</span
>
</div>
<ul
class="list-disc list-outside ml-5 space-y-1 text-foreground/80 marker:text-accent"
>
<li>
Repaired and restored QuickBooks databases, ensuring data integrity, and
minimizing downtime for small business users.
</li>
<li>
Utilized SQL for data analysis, troubleshooting, and custom Query
development to resolve complex data-related issues.
</li>
<li>
Leveraged Splunk to investigate service anomalies, identify root causes, and
support resolution workflows.
</li>
<li>
Interpreted large datasets to diagnose problems, implement fixes, and
deliver accurate and timely support solutions.
</li>
<li>
Maintained a strong focus on data privacy and customer satisfaction in a
high-volume and customercentric environment.
</li>
</ul>
</div>
<div class="relative">
<span
class="absolute -left-[43px] top-1 h-5 w-5 rounded-full border-4 border-background bg-background"
>
<span class="block h-full w-full rounded-full bg-accent/50"></span>
</span>
<h3 class="text-xl font-bold text-heading3">Tier 2 AppleCare Advisor</h3>
<div class="flex items-center justify-between mb-2">
<span class="text-accent font-semibold">Apple</span>
<span class="text-sm text-foreground/60 bg-foreground/5 px-2 py-0.5 rounded"
>Apr 2018 - Sep 2018</span
>
</div>
<ul
class="list-disc list-outside ml-5 space-y-1 text-foreground/80 marker:text-accent"
>
<li>
Provided technical support to customers via phone, email, chat, and resolved
95% of issues on the first contact.
</li>
<li>
Collaborated with Tier 1 support team to escalate complex issues and ensure
timely resolution.
</li>
<li>
Troubleshot hardware and software problems for a wide range of products
including computers, printers, routers, and mobile devices.
</li>
<li>
Demonstrated expertise in troubleshooting network connectivity issues by
analyzing logs and conducting remote diagnostics.
</li>
<li>
Assisted customers in configuring software applications and resolving
compatibility issues.
</li>
</ul>
</div>
<div class="relative">
<span
class="absolute -left-[43px] top-1 h-5 w-5 rounded-full border-4 border-background bg-background"
>
<span class="block h-full w-full rounded-full bg-accent/50"></span>
</span>
<h3 class="text-xl font-bold text-heading3">Assistant Store Manager</h3>
<div class="flex items-center justify-between mb-2">
<span class="text-accent font-semibold">Family Dollar</span>
<span class="text-sm text-foreground/60 bg-foreground/5 px-2 py-0.5 rounded"
>May 2017 - May 2018</span
>
</div>
<ul
class="list-disc list-outside ml-5 space-y-1 text-foreground/80 marker:text-accent"
>
<li>
Assisted store manager in overseeing daily operations including inventory
management, staff scheduling, and customer service.
</li>
<li>
Trained and mentored new employees on company policies, procedures, and
customer service standards.
</li>
<li>
Collaborated with the store manager to develop and implement effective
merchandising displays to drive sales.
</li>
<li>
Managed cash handling procedures and ensured accuracy and compliance with
company guidelines.
</li>
<li>
Resolved customer complaints or concerns promptly and effectively, to
maintain high levels of customer satisfaction.
</li>
</ul>
</div>
</div>
</section>
<!-- Skills Section -->
<section class="mb-12">
<BlockHeader>Skills</BlockHeader>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 my-5">
<div class="bg-accent/7 p-4 rounded-xl">
<h3 class="font-bold text-heading4 mb-3 border-b border-accent/10 pb-2">
Technical
</h3>
<div class="flex flex-wrap gap-3 text-sm">
{
[
'Computer Science',
'Problem-Solving',
'e-Commerce',
'Linux',
'Computer Operation',
'Analysis Skills',
'Typing',
].map((skill) => (
<span class="py-1 px-3 bg-accent/1 hover:bg-accent/8 border border-accent/20 text-accent/90 rounded-2xl transition-colors cursor-default">
{skill}
</span>
))
}
</div>
</div>
<div class="bg-accent/7 p-4 rounded-xl">
<h3 class="font-bold text-heading4 mb-3 border-b border-accent/10 pb-2">
General
</h3>
<div class="flex flex-wrap gap-3 text-sm">
{
[
'Customer Service',
'Leadership',
'Team Training',
'Teamwork',
'Time Management',
'Communication Skills',
].map((skill) => (
<span class="py-1 px-3 bg-accent/1 hover:bg-accent/8 border border-accent/20 text-accent/90 rounded-2xl transition-colors cursor-default">
{skill}
</span>
))
}
</div>
</div>
</div>
</section>
</div>
</Layout>
+63
View File
@@ -0,0 +1,63 @@
import type { APIRoute } from 'astro'
export const prerender = false
export const GET: APIRoute = async ({ request, cookies, redirect }) => {
const url = new URL(request.url)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const storedState = cookies.get('github_oauth_state')?.value
if (!state || !storedState || state !== storedState) {
return new Response('Invalid state', { status: 400 })
}
cookies.delete('github_oauth_state', { path: '/' })
const client_id = import.meta.env.GITHUB_CLIENT_ID
const client_secret = import.meta.env.GITHUB_CLIENT_SECRET
if (!client_id || !client_secret) {
return new Response('Missing GitHub credentials', { status: 500 })
}
try {
const response = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
client_id,
client_secret,
code,
}),
})
const data = await response.json()
if (data.error) {
return new Response(data.error_description || 'OAuth Error', { status: 400 })
}
const { access_token } = data
// Securely store the token
cookies.set('github_access_token', access_token, {
path: '/',
secure: import.meta.env.PROD,
httpOnly: true,
maxAge: 60 * 60 * 24 * 30, // 30 days
sameSite: 'lax',
})
const redirectTo = cookies.get('github_redirect_to')?.value
cookies.delete('github_redirect_to', { path: '/' })
return redirect(redirectTo || '/')
} catch (err) {
console.error(err)
return new Response('Authentication failed', { status: 500 })
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { APIRoute } from 'astro'
export const prerender = false
export const GET: APIRoute = async ({ request, redirect, cookies }) => {
const client_id = import.meta.env.GITHUB_CLIENT_ID
if (!client_id) {
return new Response('Missing GITHUB_CLIENT_ID', { status: 500 })
}
const state = crypto.randomUUID()
cookies.set('github_oauth_state', state, {
path: '/',
secure: import.meta.env.PROD,
httpOnly: true,
maxAge: 60 * 10, // 10 minutes
sameSite: 'lax',
})
const url = new URL(request.url)
const redirectTo = url.searchParams.get('redirect_to')
if (redirectTo) {
cookies.set('github_redirect_to', redirectTo, {
path: '/',
secure: import.meta.env.PROD,
httpOnly: true,
maxAge: 60 * 10, // 10 minutes
sameSite: 'lax',
})
}
// Store the return URL if provided
// We can't easily pass it through state efficiently without encoding,
// so let's check headers or a query param if we want to support deep linking back.
// For now, let's keep it simple. callback will handle redirect.
const params = new URLSearchParams({
client_id,
scope: 'public_repo read:user user:email',
state,
})
return redirect(`https://github.com/login/oauth/authorize?${params.toString()}`)
}
+8
View File
@@ -0,0 +1,8 @@
import type { APIRoute } from 'astro'
export const prerender = false
export const GET: APIRoute = async ({ cookies, redirect }) => {
cookies.delete('github_access_token', { path: '/' })
return redirect('/')
}
+33
View File
@@ -0,0 +1,33 @@
import type { APIRoute } from 'astro'
import { Octokit } from 'octokit'
export const prerender = false
export const GET: APIRoute = async ({ cookies }) => {
const token = cookies.get('github_access_token')?.value
if (!token) {
return new Response(JSON.stringify({ user: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
try {
const octokit = new Octokit({ auth: token })
const { data: user } = await octokit.rest.users.getAuthenticated()
return new Response(JSON.stringify({ user }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
} catch (error) {
console.error('Error fetching user:', error)
// If token is invalid, clear it?
// cookies.delete('github_access_token', { path: '/' })
return new Response(JSON.stringify({ user: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
}
+71
View File
@@ -0,0 +1,71 @@
import type { APIRoute } from 'astro'
export const prerender = false
import Stripe from 'stripe'
// Use a dummy key if env var is missing to allow build to pass (e.g. during static generation or if user hasn't set it yet)
const STRIPE_SECRET_KEY = import.meta.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY || 'sk_test_dummy'
if (STRIPE_SECRET_KEY === 'sk_test_dummy') {
console.warn('Using dummy Stripe key. Checkout will fail.')
}
const stripe = new Stripe(STRIPE_SECRET_KEY)
export const POST: APIRoute = async ({ request, redirect }) => {
let amount: number
let isCustom: boolean
const contentType = request.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
const json = await request.json()
amount = parseFloat(json.amount || '0')
isCustom = json.isCustom === true
} else if (contentType.includes('multipart/form-data') || contentType.includes('application/x-www-form-urlencoded')) {
const data = await request.formData()
amount = parseFloat(data.get('amount')?.toString() || '0')
isCustom = data.get('isCustom') === 'true'
} else {
console.error('Unexpected Content-Type:', contentType)
return new Response(JSON.stringify({ error: 'Invalid Content-Type' }), { status: 400 })
}
if (!amount || amount <= 0) {
return new Response(JSON.stringify({ error: 'Invalid amount' }), { status: 400 })
}
// Ensure minimum amount for Stripe (usually $0.50 USD)
if (amount < 0.5) {
return new Response(JSON.stringify({ error: 'Amount must be at least $0.50' }), { status: 400 })
}
try {
const session = await stripe.checkout.sessions.create({
line_items: [
{
price_data: {
currency: 'usd',
product_data: {
name: 'Buy me a coffee',
description: isCustom ? 'Custom donation' : `Support for FoggyMtnDrifter`,
},
unit_amount: Math.round(amount * 100), // Stripe expects cents
},
quantity: 1,
},
],
mode: 'payment',
success_url: `${request.headers.get('origin')}/?success=true`,
cancel_url: `${request.headers.get('origin')}/?canceled=true`,
})
if (session.url) {
return new Response(JSON.stringify({ url: session.url }), { status: 200 })
}
return new Response(JSON.stringify({ error: 'Failed to create session' }), { status: 500 })
} catch (err) {
console.error('Stripe error:', err)
return new Response(JSON.stringify({ error: (err as Error).message }), { status: 500 })
}
}
+278
View File
@@ -0,0 +1,278 @@
import type { APIRoute } from 'astro'
import { Octokit } from 'octokit'
import siteConfig from '~/site.config'
export const prerender = false
const GITHUB_TOKEN = import.meta.env.GITHUB_PERSONAL_ACCESS_TOKEN
const OWNER = 'FoggyMtnDrifter'
const REPO = 'website'
// Helper to get Octokit instance
const getOctokit = (auth?: string) => new Octokit({ auth: auth || GITHUB_TOKEN })
export const GET: APIRoute = async ({ params, request }) => {
if (!siteConfig.comments) {
return new Response(JSON.stringify({ error: 'Comments not configured' }), { status: 500 })
}
const { slug } = params
// Mapping is pathname. With the slug, we construct the pathname.
// Assuming slugs align with pathnames. E.g. posts/slug -> /posts/slug OR just The Title?
// "pathname" mapping usually uses the pathname of the page.
// The user's pages are at /posts/[slug]. So search term is likely `/posts/${slug}` or just `/${slug}`?
// Let's assume `/posts/${slug}` based on typical structure.
// Wait, the slug param might capture the whole path if it was [...slug], but it's [slug].
// Let's verify the logic in `src/pages/posts/[slug].astro`.
// The comments loader uses `pathname`.
const term = `/posts/${slug}`
const query = `
query($term: String!) {
search(type: DISCUSSION, query: $term, first: 1) {
nodes {
... on Discussion {
id
title
number
comments(first: 100) {
nodes {
id
body
bodyHTML
createdAt
author {
login
avatarUrl
url
}
replies(first: 20) {
nodes {
id
body
bodyHTML
createdAt
author {
login
avatarUrl
url
}
}
}
}
}
}
}
}
}
`
try {
const octokit = getOctokit()
const searchQuery = `repo:${siteConfig.comments.repo} in:title ${term}`
const result: any = await octokit.graphql(query, {
term: searchQuery
})
const discussion = result.search?.nodes?.[0]
if (discussion && discussion.comments) {
const transformComment = (comment: any) => {
const attributionMatch = comment.body.match(/\n\n_\(Posted by (.*?)\)_$/)
if (attributionMatch) {
const displayName = attributionMatch[1]
comment.author.login = displayName
comment.author.url = '' // Guest has no profile
// Use DiceBear for guest avatar
comment.author.avatarUrl = `https://api.dicebear.com/9.x/initials/svg?seed=${encodeURIComponent(displayName)}`
}
// The HTML is: <p dir="auto"><em>(Posted by Name)</em></p>
// parsing HTML with regex is fragile but for this specific pattern it's consistent from GitHub
// We accept optional whitespace and attributes on the p tag.
if (comment.bodyHTML) {
comment.bodyHTML = comment.bodyHTML.replace(/<p[^>]*>\s*<em>\(Posted by .*?\)<\/em>\s*<\/p>\s*$/, '')
}
if (comment.replies && comment.replies.nodes) {
comment.replies.nodes.forEach(transformComment)
// Sort replies: Newest first
comment.replies.nodes.reverse()
}
}
discussion.comments.nodes.forEach(transformComment)
// Sort comments: Newest first
discussion.comments.nodes.reverse()
}
return new Response(JSON.stringify({ discussion }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
console.error('Error fetching comments:', error)
return new Response(JSON.stringify({ error: 'Failed to fetch comments' }), { status: 500 })
}
}
export const POST: APIRoute = async ({ params, request, cookies }) => {
const { slug } = params
const term = `/posts/${slug}`
let body
try {
body = await request.json()
} catch (e) {
return new Response('Invalid JSON', { status: 400 })
}
const { content, displayName, discussionId, website_honey, captcha } = body
const userToken = cookies.get('github_access_token')?.value
// 1. Honeypot Check
if (website_honey) {
// Silently fail or return error. Let's return error to stop processing.
console.log('Honeypot triggered')
return new Response('Spam detected', { status: 400 })
}
// 2. Basic Math Captcha Check (for guests or everyone? User said safeguards. Let's apply to anonymous mainly, but maybe all for safety? Let's apply to ANONYMOUS only as auth users are trusted)
if (!userToken) {
if (!captcha || !captcha.num1 || !captcha.num2 || !captcha.answer) {
return new Response('Captcha required', { status: 400 })
}
const { num1, num2, answer } = captcha
if (parseInt(num1) + parseInt(num2) !== parseInt(answer)) {
return new Response('Incorrect math answer', { status: 400 })
}
}
// Decide which token and attribution to use
let finalContent = content
let auth = userToken
if (!auth) {
// Anonymous mode
if (!displayName) {
return new Response('Display name required for anonymous comments', { status: 400 })
}
auth = GITHUB_TOKEN // Use bot token
finalContent = `${content}\n\n_(Posted by ${displayName})_`
}
if (!auth) {
return new Response('Server configuration error: No bot token available', { status: 500 })
}
const octokit = getOctokit(auth)
try {
let finalDiscussionId = discussionId
// If no discussion ID provided, we must find or create it.
// This requires the BOT token usually, as regular users might not have permission to create discussions in the category?
// Actually, users usually CAN create discussions if the repo is public.
// But for consistency, let's look it up first.
if (!finalDiscussionId) {
// Logic to find or create discussion...
// Re-use logic from GET or extracting it to a shared helper would be better.
// For now, let's just error if not found to keep it simple, or implement create logic.
// Let's implement Find-Or-Create logic using Bot Token (to ensure it's created correctly).
const botOctokit = getOctokit(GITHUB_TOKEN)
// 1. Find
const searchQuery = `repo:${siteConfig.comments!.repo} in:title ${term}`
const findQuery = `
query($term: String!) {
search(type: DISCUSSION, query: $term, first: 1) {
nodes { ... on Discussion { id } }
}
}
`
const findResult: any = await botOctokit.graphql(findQuery, { term: searchQuery })
const found = findResult.search.nodes[0]
if (found) {
finalDiscussionId = found.id
} else {
// 2. Create
if (!siteConfig.comments?.repoId || !siteConfig.comments?.categoryId) {
return new Response('Missing repoId or categoryId configuration', { status: 500 })
}
const createQuery = `
mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
createDiscussion(input: {
repositoryId: $repositoryId,
categoryId: $categoryId,
title: $title,
body: $body
}) {
discussion { id }
}
}
`
// GitHub Discussions usually creates it with a specific body.
const createResult: any = await botOctokit.graphql(createQuery, {
repositoryId: siteConfig.comments.repoId,
categoryId: siteConfig.comments.categoryId,
title: term,
body: `Comments for ${term}\n\n[View Post](${new URL(term, siteConfig.site)})`
})
finalDiscussionId = createResult.createDiscussion.discussion.id
}
}
const createCommentQuery = `
mutation($discussionId: ID!, $body: String!) {
addDiscussionComment(input: {discussionId: $discussionId, body: $body}) {
comment {
id
body
bodyHTML
createdAt
author {
login
avatarUrl
url
}
}
}
}
`
const response: any = await octokit.graphql(createCommentQuery, {
discussionId: finalDiscussionId,
body: finalContent
})
const newComment = response.addDiscussionComment.comment
// If it was an anonymous post, we want to return it with the display name immediately
// so the UI updates correctly without a refetch.
if (!userToken && displayName) {
newComment.author.login = displayName
newComment.author.url = ''
newComment.author.avatarUrl = `https://api.dicebear.com/9.x/initials/svg?seed=${encodeURIComponent(displayName)}`
// Strip the attribution from bodyHTML if present
if (newComment.bodyHTML) {
newComment.bodyHTML = newComment.bodyHTML.replace(/<p[^>]*>\s*<em>\(Posted by .*?\)<\/em>\s*<\/p>\s*$/, '')
}
}
return new Response(JSON.stringify(newComment), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
console.error('Error posting comment:', error)
return new Response(JSON.stringify({ error: 'Failed to post comment', details: error }), { status: 500 })
}
}
+40
View File
@@ -0,0 +1,40 @@
import type { APIRoute } from 'astro'
export const prerender = false
import Stripe from 'stripe'
// Use a dummy key if env var is missing to allow build to pass
const STRIPE_SECRET_KEY = import.meta.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY || 'sk_test_dummy'
const stripe = new Stripe(STRIPE_SECRET_KEY)
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json()
const amount = parseFloat(body.amount)
const isCustom = body.isCustom === true
if (!amount || amount <= 0) {
return new Response(JSON.stringify({ error: 'Invalid amount' }), { status: 400 })
}
// Ensure minimum amount for Stripe (custom requirement: $5 USD)
if (amount < 5) {
return new Response(JSON.stringify({ error: 'Amount must be at least $5.00' }), { status: 400 })
}
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100), // Stripe expects cents
currency: 'usd',
automatic_payment_methods: {
enabled: true,
},
description: isCustom ? 'Custom donation' : 'Support for FoggyMtnDrifter',
})
return new Response(JSON.stringify({ clientSecret: paymentIntent.client_secret }), { status: 200 })
} catch (err) {
console.error('Stripe error:', err)
return new Response(JSON.stringify({ error: (err as Error).message }), { status: 500 })
}
}
+76
View File
@@ -0,0 +1,76 @@
---
import Layout from '~/layouts/Layout.astro'
import { getSortedPosts } from '~/utils'
import { getCollection, render } from 'astro:content'
import PostPreview from '~/components/PostPreview.astro'
import Pagination from '~/components/Pagination.astro'
import BlockHeader from '~/components/BlockHeader.astro'
import HomeBanner from '~/components/HomeBanner.astro'
import siteConfig from '~/site.config'
import TagsSection from '~/components/TagsSection.astro'
import SeriesSection from '~/components/SeriesSection.astro'
const home = await getCollection('home')
let HomeContent
let homeAvatarImage
let homeGithubCalendar
if (home.length > 0) {
const homeEntry = home[0]
const { Content } = await render(homeEntry)
HomeContent = Content
homeAvatarImage = homeEntry.data.avatarImage
homeGithubCalendar = homeEntry.data.githubCalendar
}
const sortedPosts = await getSortedPosts()
const postsHaveTags = sortedPosts.some(
(post) => post.data.tags && post.data.tags.length > 0,
)
const postsHaveSeries = sortedPosts.some((post) => post.data.series)
---
<Layout>
{
HomeContent && (
<HomeBanner avatarImage={homeAvatarImage} githubCalendar={homeGithubCalendar}>
<HomeContent />
</HomeBanner>
)
}
{
postsHaveSeries && (
<section>
<BlockHeader>Series</BlockHeader>
<SeriesSection posts={sortedPosts} />
</section>
)
}
{
postsHaveTags && (
<section>
<BlockHeader>Tags</BlockHeader>
<TagsSection posts={sortedPosts} />
</section>
)
}
{
sortedPosts.length > 0 && (
<section>
<BlockHeader>Latest Posts</BlockHeader>
{sortedPosts
.reverse()
.slice(0, Math.floor(siteConfig.pageSize / 2))
.map((post) => (
<PostPreview post={post} />
))}
<Pagination nextLink="/posts" nextText="All Posts" />
</section>
)
}
</Layout>
<style is:global>
a.heading-anchor {
display: none !important;
}
</style>
+32
View File
@@ -0,0 +1,32 @@
---
import { getCollection, render } from 'astro:content'
import Layout from '~/layouts/Layout.astro'
import type { GetStaticPaths } from 'astro'
export const getStaticPaths = (async () => {
const legalEntries = await getCollection('legal')
return legalEntries.map((entry) => ({
params: { slug: entry.id },
props: { entry },
}))
}) satisfies GetStaticPaths
const { entry } = Astro.props
const { Content } = await render(entry)
---
<Layout title={entry.data.title}>
<article class="max-w-full py-7.5 prose dark:prose-invert mx-auto">
<Content />
<hr class="my-8 border-accent/10" />
<p class="text-sm text-gray-500 italic">
Last updated: {
entry.data.updated.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
</p>
</article>
</Layout>
+31
View File
@@ -0,0 +1,31 @@
---
import type { GetStaticPaths } from 'astro'
import Layout from '~/layouts/Layout.astro'
import { getSortedPosts } from '~/utils'
import Pagination from '~/components/Pagination.astro'
import siteConfig from '~/site.config'
import PostPreview from '~/components/PostPreview.astro'
import PageHeader from '~/components/PageHeader.astro'
// Note: Pagination like '/', '/2', '/3' only works with spread param like [...page]
export const getStaticPaths = (async ({ paginate }) => {
const sortedPosts = await getSortedPosts()
return paginate(sortedPosts.reverse(), { pageSize: siteConfig.pageSize })
}) satisfies GetStaticPaths
const { page } = Astro.props
const pageTitle = 'Archive' + (page.currentPage > 1 ? ` - Page ${page.currentPage}` : '')
---
<Layout title={pageTitle} description="All posts in the archive">
<div class="mt-2 sm:mt-0">
<PageHeader />
{page.data.map((post) => <PostPreview post={post} />)}
<Pagination
prevLink={page.url.prev ? page.url.prev : undefined}
prevText="Newer Posts"
nextLink={page.url.next ? page.url.next : undefined}
nextText="Older Posts"
/>
</div>
</Layout>
+149
View File
@@ -0,0 +1,149 @@
---
import type { GetStaticPaths } from 'astro'
import Layout from '~/layouts/Layout.astro'
import { SeriesGroup, TagsGroup, getSortedPosts } from '~/utils'
import PostPreview from '~/components/PostPreview.astro'
import DividerText from '~/components/DividerText.astro'
import { getCollection, render, type CollectionEntry } from 'astro:content'
import PostAddendum from '~/components/PostAddendum.astro'
import TableOfContents from '~/components/TableOfContents.astro'
import { Image } from 'astro:assets'
import Comments from '~/components/Comments.svelte'
import siteConfig from '~/site.config'
import Tags from '~/components/Tags.astro'
import PostInfo from '~/components/PostInfo.astro'
import ScrollUpButton from '~/components/ScrollUpButton.astro'
import type { Collation } from '~/types'
import ChevronsRight from '~/icons/chevrons-right.svg'
import { getPostSequenceContext } from '~/utils'
export const getStaticPaths = (async () => {
const posts = await getSortedPosts()
return posts.map((post) => {
// Get sequence context for the post
const { prev, next } = getPostSequenceContext(post, posts)
return {
params: { slug: post.id },
props: { post, prev, next },
}
})
}) satisfies GetStaticPaths
const { post, prev, next } = Astro.props
const postData = post.data
const { headings, Content: PostContent } = await render(post)
const addendum = await getCollection('addendum')
let AddendumContent
let addendumAvatarImage
if (addendum.length > 0) {
const addendumEntry = addendum[0]
const { Content } = await render(addendumEntry)
AddendumContent = Content
addendumAvatarImage = addendumEntry.data.avatarImage
}
const sortedPosts = await getSortedPosts()
// Get series posts if this post is part of a series
let series: Collation<'posts'> | undefined
let nextPostInSeries: CollectionEntry<'posts'> | undefined
if (postData.series) {
const seriesGroup = await SeriesGroup.build(sortedPosts)
series = seriesGroup.match(postData.series)
if (!series) {
// This should not happen if series data is correct
throw new Error(`Series "${postData.series}" not found`)
}
const sequenceContext = getPostSequenceContext(post, series.entries)
nextPostInSeries = sequenceContext.next
}
const showSeries = series && series.entries.length > 1
let tags: Collation<'posts'>[] | undefined
if (postData.tags && postData.tags.length > 0) {
const tagsGroup = await TagsGroup.build(sortedPosts)
tags = tagsGroup.matchMany(postData.tags)
}
---
<Layout
title={postData.title}
description={postData.description}
author={postData.author}
tags={postData.tags}
>
<article class="max-w-full py-7.5" data-pagefind-body>
{
postData.coverImage && (
<Image
priority
layout="constrained"
src={postData.coverImage.src}
alt={postData.coverImage.alt}
class="w-full rounded-xl mb-5"
/>
)
}
<div class="md:mx-2">
<h1 id={post.id} class="mb-4 text-[1.75rem] text-heading1 font-semibold">
{postData.title}
</h1>
<div class="my-2 border-l-2 border-accent/80 pl-4 py-2">
<PostInfo post={post} class="mb-1" />
{
tags && (
<div class="mt-4">
<Tags tags={tags} />
</div>
)
}
</div>
</div>
<!-- <hr class="border-accent/10 border-2 rounded-xl hidden lg:block" /> -->
<div class="flex flex-col xl:gap-1 2xl:gap-18 xl:flex-row xl:items-start">
{postData.toc && headings.length > 0 && <TableOfContents headings={headings} />}
<div class="mb-5 xl:min-w-full 2xl:min-w-full prose">
<PostContent />
</div>
</div>
</article>
{
nextPostInSeries && (
<a
href={`/posts/${nextPostInSeries.id}`}
class="button justify-center -mt-8 mb-8 !whitespace-normal text-center flex gap-2 sm:gap-3 items-center"
>
<span>Next: {nextPostInSeries.data.title}</span>
<ChevronsRight class="size-5 hidden sm:block" />
</a>
)
}
{
AddendumContent && (
<PostAddendum avatarImage={addendumAvatarImage}>
<AddendumContent />
</PostAddendum>
)
}
{
showSeries && series ? (
<section>
<DividerText text={`${series.title} Series`} />
{series.entries.map((seriesPost) => (
<PostPreview post={seriesPost} />
))}
</section>
) : prev || next ? (
<section>
<DividerText text="More Posts" />
{prev && <PostPreview post={prev} />}
{next && <PostPreview post={next} />}
</section>
) : null
}
<section>
<DividerText text="Comments" />
<Comments slug={post.id} client:visible />
</section>
<ScrollUpButton />
</Layout>
+13
View File
@@ -0,0 +1,13 @@
import type { APIRoute } from 'astro'
const getRobotsTxt = (sitemapURL: URL) => `\
User-agent: *
Allow: /
Sitemap: ${sitemapURL.href}
`
export const GET: APIRoute = ({ site }) => {
const sitemapURL = new URL('sitemap-index.xml', site)
return new Response(getRobotsTxt(sitemapURL))
}
+35
View File
@@ -0,0 +1,35 @@
import rss from '@astrojs/rss'
import siteConfig from '~/site.config'
import type { AstroGlobal } from 'astro'
import { getSortedPosts } from '~/utils'
import sanitizeHtml from 'sanitize-html'
import MarkdownIt from 'markdown-it'
const parser = new MarkdownIt()
// https://docs.astro.build/en/recipes/rss/
export async function GET(_context: AstroGlobal) {
if (!siteConfig.site) {
console.warn(
'Site URL is required for RSS feed generation. Skipping RSS feed generation.',
)
return
}
const posts = await getSortedPosts()
return rss({
stylesheet: '/rss.xsl',
title: siteConfig.title,
description: siteConfig.description,
site: siteConfig.site,
items: posts.map((post) => ({
title: post.data.title,
pubDate: post.data.published,
description: post.data.description,
author: post.data.author || siteConfig.author,
link: `/posts/${post.id}`,
content: sanitizeHtml(parser.render(post.body || ''), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
}),
})),
trailingSlash: false,
})
}
+29
View File
@@ -0,0 +1,29 @@
---
import type { GetStaticPaths } from 'astro'
import Layout from '~/layouts/Layout.astro'
import { SeriesGroup } from '~/utils'
import PageHeader from '~/components/PageHeader.astro'
import PostPreview from '~/components/PostPreview.astro'
export const getStaticPaths = (async () => {
const seriesGroup = await SeriesGroup.build()
return seriesGroup.collations.map((series) => {
return {
params: { slug: series.titleSlug },
props: { posts: series.entries, seriesTitle: series.title },
}
})
}) satisfies GetStaticPaths
const { posts, seriesTitle } = Astro.props
---
<Layout
title={`Series: ${seriesTitle}`}
description={`All posts in the ${seriesTitle} series`}
>
<div class="mt-2 sm:mt-0">
<PageHeader titlePieces={['series', seriesTitle]} />
{posts.map((post) => <PostPreview post={post} />)}
</div>
</Layout>
+109
View File
@@ -0,0 +1,109 @@
import siteConfig from '~/site.config'
import { Resvg } from '@resvg/resvg-js'
import type { APIContext, InferGetStaticPropsType } from 'astro'
import satori, { type SatoriOptions } from 'satori'
import { html } from 'satori-html'
import { dateString, getSortedPosts, resolveThemeColorStyles } from '~/utils'
import path from 'path'
import fs from 'fs'
import type { ReactNode } from 'react'
// Load the font file as binary data
const fontPath = path.resolve(
'./node_modules/@expo-google-fonts/jetbrains-mono/400Regular/JetBrainsMono_400Regular.ttf',
)
const fontData = fs.readFileSync(fontPath) // Reads the file as a Buffer
const avatarPath = path.resolve(siteConfig.socialCardAvatarImage)
let avatarData: Buffer | undefined
let avatarBase64: string | undefined
if (
fs.existsSync(avatarPath) &&
(path.extname(avatarPath).toLowerCase() === '.jpg' ||
path.extname(avatarPath).toLowerCase() === '.jpeg')
) {
avatarData = fs.readFileSync(avatarPath)
avatarBase64 = `data:image/jpeg;base64,${avatarData.toString('base64')}`
}
const defaultTheme =
siteConfig.themes.default === 'auto'
? siteConfig.themes.include[0]
: siteConfig.themes.default
const themeStyles = await resolveThemeColorStyles(
[defaultTheme],
siteConfig.themes.overrides,
)
const bg = themeStyles[defaultTheme]?.background
const fg = themeStyles[defaultTheme]?.foreground
const accent = themeStyles[defaultTheme]?.accent
if (!bg || !fg || !accent) {
throw new Error(`Theme ${defaultTheme} does not have required colors`)
}
const ogOptions: SatoriOptions = {
// debug: true,
fonts: [
{
data: fontData,
name: 'JetBrains Mono',
style: 'normal',
weight: 400,
},
],
height: 630,
width: 1200,
}
const markup = (title: string, pubDate: string | undefined, author: string) =>
html(`<div tw="flex flex-col max-w-full justify-center h-full bg-[${bg}] text-[${fg}] p-12">
<div style="border-width: 12px; border-radius: 80px;" tw="flex items-center max-w-full p-8 border-[${accent}]/30">
${
avatarBase64
? `<div tw="flex flex-col justify-center items-center w-1/3 h-100">
<img src="${avatarBase64}" tw="flex w-full rounded-full border-[${accent}]/30" />
</div>`
: ''
}
<div tw="flex flex-1 flex-col max-w-full justify-center items-center">
${pubDate ? `<p tw="text-3xl max-w-full text-[${accent}]">${pubDate}</p>` : ''}
<h1 tw="text-6xl my-14 text-center leading-snug">${title}</h1>
${author !== title ? `<p tw="text-4xl text-[${accent}]">${author}</p>` : ''}
</div>
</div>
</div>`)
type Props = InferGetStaticPropsType<typeof getStaticPaths>
export async function GET(context: APIContext) {
const { pubDate, title, author } = context.props as Props
const svg = await satori(markup(title, pubDate, author) as ReactNode, ogOptions)
const png = new Resvg(svg).render().asPng()
return new Response(png, {
headers: {
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Type': 'image/png',
},
})
}
export async function getStaticPaths() {
const posts = await getSortedPosts()
return posts
.map((post) => ({
params: { slug: post.id },
props: {
pubDate: post.data.published ? dateString(post.data.published) : undefined,
title: post.data.title,
author: post.data.author || siteConfig.author,
},
}))
.concat([
{
params: { slug: '__default' },
props: { pubDate: undefined, title: siteConfig.title, author: siteConfig.author },
},
])
}
+40
View File
@@ -0,0 +1,40 @@
---
import type { GetStaticPaths } from 'astro'
import Layout from '~/layouts/Layout.astro'
import { TagsGroup } from '~/utils'
import Pagination from '~/components/Pagination.astro'
import siteConfig from '~/site.config'
import PostPreview from '~/components/PostPreview.astro'
import PageHeader from '~/components/PageHeader.astro'
// Note: Pagination like '/', '/2', '/3' only works with spread param like [...page]
export const getStaticPaths = (async ({ paginate }) => {
const tagsGroup = await TagsGroup.build()
const pages = tagsGroup.collations.flatMap((tags) => {
// Use flatMap to lift the posts for each tag into a single array
return paginate(tags.entries.reverse(), {
props: { tagTitle: tags.title },
params: { tag: tags.titleSlug },
pageSize: siteConfig.pageSize,
})
})
return pages
}) satisfies GetStaticPaths
const { page, tagTitle } = Astro.props
const pageTitle =
`Tag: ${tagTitle}` + (page.currentPage > 1 ? ` - Page ${page.currentPage}` : '')
---
<Layout title={pageTitle} description={`All posts tagged with ${tagTitle}`}>
<div class="mt-2 sm:mt-0">
<PageHeader titlePieces={['tags', tagTitle]} />
{page.data.map((post) => <PostPreview post={post} />)}
<Pagination
prevLink={page.url.prev ? encodeURI(page.url.prev) : undefined}
prevText="Newer Posts"
nextLink={page.url.next ? encodeURI(page.url.next) : undefined}
nextText="Older Posts"
/>
</div>
</Layout>
+19
View File
@@ -0,0 +1,19 @@
import { visit } from 'unist-util-visit'
import type { Plugin } from 'unified'
import type { Root } from 'hast'
const plugin: Plugin<[], Root> = () => {
return function transformer(tree) {
visit(tree, 'element', (el) => {
if (el.tagName === 'img') {
const alt = el.properties?.alt
if (alt && typeof alt === 'string' && alt.endsWith('#pixelated')) {
el.properties['data-pixelated'] = true
el.properties.alt = alt.substring(0, alt.length - '#pixelated'.length).trim()
}
}
})
}
}
export default plugin
+34
View File
@@ -0,0 +1,34 @@
import type * as hast from 'hast'
import type { RehypePlugin } from '@astrojs/markdown-remark'
import { h } from 'hastscript'
export const rehypeTitleFigure: RehypePlugin = (_options?) => {
function buildFigure(el: hast.Element) {
const title = `${el.properties?.title || ''}`
if (!title) return el
const figure = h('figure', [h('img', { ...el.properties }), h('figcaption', title)])
return figure
}
function isElement(content: hast.RootContent): content is hast.Element {
return content.type === 'element'
}
function transformTree(node: hast.Root | hast.Element) {
if (node.children) {
node.children = node.children.map((child) => {
if (isElement(child)) {
if (child.tagName === 'img') {
return buildFigure(child)
} else {
transformTree(child) // Recursively process child nodes
}
}
return child
})
}
}
return function (tree: hast.Root) {
transformTree(tree) // Start the recursive transformation
}
}
export default rehypeTitleFigure
+73
View File
@@ -0,0 +1,73 @@
import type { PhrasingContent, Root } from 'mdast'
import { toString as mdastToString } from 'mdast-util-to-string'
import type { Plugin } from 'unified'
import { visit } from 'unist-util-visit'
import type { AdmonitionType } from '~/types'
import { h as _h, type Properties } from 'hastscript'
import type { Paragraph as P } from 'mdast'
/** From Astro Starlight: Function that generates an mdast HTML tree ready for conversion to HTML by rehype. */
function h(el: string, attrs: Properties = {}, children: any[] = []): P {
const { properties, tagName } = _h(el, attrs)
return {
children,
data: { hName: tagName, hProperties: properties },
type: 'paragraph',
}
}
// Supported admonition types
const Admonitions = new Set<AdmonitionType>([
'tip',
'note',
'important',
'caution',
'warning',
])
/** Checks if a string is a supported admonition type. */
function isAdmonition(s: string): s is AdmonitionType {
return Admonitions.has(s as AdmonitionType)
}
export const remarkAdmonitions: Plugin<[], Root> = () => (tree) => {
visit(tree, (node, index, parent) => {
if (!parent || index === undefined || node.type !== 'containerDirective') return
const admonitionType = node.name
if (!isAdmonition(admonitionType)) return
let title: string = admonitionType
let titleNode: PhrasingContent[] = [{ type: 'text', value: title }]
// Check if there's a custom title
const firstChild = node.children[0]
if (
firstChild?.type === 'paragraph' &&
firstChild.data &&
'directiveLabel' in firstChild.data &&
firstChild.children.length > 0
) {
titleNode = firstChild.children
title = mdastToString(firstChild.children)
// The first paragraph contains a custom title, we can safely remove it.
node.children.splice(0, 1)
}
// Do not change prefix to AD, ADM, or similar, adblocks will block the content inside.
const admonition = h(
'aside',
{
'aria-label': title,
class: 'admonition',
'data-admonition-type': admonitionType,
},
[
h('p', { class: 'admonition-title', 'aria-hidden': 'true' }, [...titleNode]),
h('div', { class: 'admonition-content' }, node.children),
],
)
parent.children[index] = admonition
})
}
+62
View File
@@ -0,0 +1,62 @@
import type { Root } from 'mdast'
import type { Plugin } from 'unified'
import { visit } from 'unist-util-visit'
import { h as _h, type Properties } from 'hastscript'
import type { Paragraph as P } from 'mdast'
/** From Astro Starlight: Function that generates an mdast HTML tree ready for conversion to HTML by rehype. */
function h(el: string, attrs: Properties = {}, children: any[] = []): P {
const { properties, tagName } = _h(el, attrs)
return {
children,
data: { hName: tagName, hProperties: properties },
type: 'paragraph',
}
}
const remarkCharacterDialogue: Plugin<[{ characters: Record<string, string> }], Root> =
(opts) => (tree) => {
// Type guard to check if a string is a valid character dialogue key
function isCharacterDialogue(s: string): s is keyof typeof opts.characters {
return opts.characters.hasOwnProperty(s) && opts.characters[s] !== undefined
}
// Do nothing if no characters are defined
if (!opts.characters || Object.keys(opts.characters).length === 0) {
return
}
visit(tree, (node, index, parent) => {
if (!parent || index === undefined || node.type !== 'containerDirective') return
const characterName = node.name
if (!isCharacterDialogue(characterName)) return
const align = node.attributes?.align ?? null
const alignClass = align === 'left' || align === 'right' ? ` align-${align}` : ''
// Do not change prefix to AD, ADM, or similar, adblocks will block the content inside.
const admonition = h(
'aside',
{
'aria-label': `Character dialogue: ${characterName}`,
class: 'character-dialogue' + alignClass,
'data-character': characterName,
},
[
h('img', {
class: 'character-dialogue-image',
alt: characterName,
loading: 'lazy',
src: opts.characters[characterName],
width: 100,
}),
h('div', { class: 'character-dialogue-content' }, node.children),
],
)
parent.children[index] = admonition
})
}
export default remarkCharacterDialogue
+43
View File
@@ -0,0 +1,43 @@
import type * as mdast from 'mdast'
import type { RemarkPlugin } from '@astrojs/markdown-remark'
import { toString } from 'mdast-util-to-string'
const remarkDescription: RemarkPlugin = (options?: { maxChars?: number }) => {
const maxChars = (options && options.maxChars) || 200
return function (tree, { data }) {
function findFirstParagraph(
node: mdast.Root | mdast.RootContent,
): string | undefined {
if ('children' in node && Array.isArray(node.children)) {
for (const child of node.children) {
if (
child.type === 'paragraph' &&
child.children.length > 0 &&
child.children[0].type !== 'image'
) {
const s = toString(child).trim()
if (s.length > 0) {
return s
}
} else {
const result = findFirstParagraph(child)
if (result) {
return result
}
}
}
}
return undefined
}
let description = data.astro?.frontmatter?.description || findFirstParagraph(tree)
if (description && data.astro?.frontmatter) {
if (description.length > maxChars) {
const lastSpace = description.slice(0, maxChars).lastIndexOf(' ')
description = description.slice(0, lastSpace) + '…'
}
data.astro.frontmatter.description = description
}
}
}
export default remarkDescription
+44
View File
@@ -0,0 +1,44 @@
import type { Root, Text } from 'mdast'
import type { Plugin } from 'unified'
import { nameToEmoji, emojiToName } from 'gemoji'
import emojiRegex from 'emoji-regex'
import { findAndReplace } from 'mdast-util-find-and-replace'
function emojiSpan(emojiLiteral: string, emojiDescription: string): Text {
return {
type: 'text',
value: emojiLiteral,
data: {
hName: 'span',
hProperties: { role: 'img', ariaLabel: emojiDescription.replace(/_/g, ' ') },
hChildren: [{ type: 'text', value: emojiLiteral }],
},
}
}
/**
* Plugin to replace emoji shortcodes with their corresponding emoji characters.
* It uses the `gemoji` package to map shortcode names to emoji characters.
*/
const plugin: Plugin<[], Root> = () => (tree) => {
findAndReplace(tree, [
[
/:(\+1|[-\w]+):/g,
(_: string, emojiShortcode: string) => {
return Object.hasOwn(nameToEmoji, emojiShortcode)
? emojiSpan(nameToEmoji[emojiShortcode], emojiShortcode)
: false
},
],
[
emojiRegex(),
(emojiLiteral: string) => {
return Object.hasOwn(emojiToName, emojiLiteral)
? emojiSpan(emojiLiteral, emojiToName[emojiLiteral])
: false
},
],
])
}
export default plugin

Some files were not shown because too many files have changed in this diff Show More