Saving state of blog

This commit is contained in:
Luke Hagar
2025-11-18 03:47:11 +00:00
commit c549bdf136
49 changed files with 2492 additions and 0 deletions

95
.gitignore vendored Normal file
View File

@@ -0,0 +1,95 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build outputs
dist/
.astro/
.output/
build/
# Environment variables
.env
.env.local
.env.production
.env.*.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
*~
# IDE files
.vscode/
.idea/
*.swp
*.swo
*.swn
*.suo
*.user
*.userosscache
*.sln.docstates
.project
.classpath
.settings/
*.iml
# TypeScript
*.tsbuildinfo
# Cache directories
.cache/
.parcel-cache/
.turbo/
.next/
.nuxt/
.vite/
.svelte-kit/
# Bun
.bun/
# Testing
coverage/
.nyc_output/
*.lcov
# Temporary files
*.tmp
*.temp
.tmp/
.temp/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Lock files (uncomment if you want to ignore them)
# package-lock.json
# yarn.lock
# pnpm-lock.yaml
# bun.lockb

67
README.md Normal file
View File

@@ -0,0 +1,67 @@
# Blog
This templates used [Astro](https://astro.build/), [Tailwind](https://tailwindcss.com/), and [Skeleton](https://www.skeletonlabs.co/) along with a curated template created and implemented by the the creators of Skeleton.
## How To Install
Find installation instructions within the FAQ here:
https://github.com/skeletonlabs/skeleton-templates
## Themes
This template comes pre-configured with the "Cerberus" theme. See customizations in `/src/styles/global.css`.
[Learn more about customizing themes here](https://www.skeleton.dev/docs/design/themes).
## Project Structure
The following is located in `/src`.
- `/components` - the local components such as header, footer, etc.
- `/content` - The MDX content or blog post and authors.
- `/layouts` - The page and blog post layouts.
- `/pages` - The individual page templates.
- `/styles` - Contains the global stylesheet `globals.css`.
- `constants.ts` - Static data shared throughout the application.
- `content.conig.ts` - Uses Zod to enable type safety for [Astro Content Collections](https://docs.astro.build/en/reference/modules/astro-content/).
## Media
Media such as blog images can be located in the `/public` directory.
## Routes
All rountes can be found in the `/src/routes` directory. This template includes:
- `/` - the homepage of the website.
- `/blog` - a grid list of the blogroll.
- `/blog/{postFileName}` - each individual blog post.
- `/about` - learn more about the project.
- `/sandbox` - a hidden sandbox page for testing theme styles.
## Testing Sandbox
A hidden sandbox page has been provided at `/sandbox`. This allows you to quickly preview and test various Skeleton elements and components, including: typography, buttons, the color palette, and more. This route can be deleted at your own discretion.
## Additional Assets
- Icons from https://lucide.dev/
- Images from Unsplash https://unsplash.com/
## License
This template is served under the terms of the [Personal License](https://v2.skeleton.dev/docs/sponsorship/licensing). Contact [Skeleton Labs](mailto:admin@skeletonlabs.dev) if you are interested in obtaining either a Commercial or Enterprise license.
## Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `pnpm install` | Installs dependencies |
| `pnpm dev` | Starts local dev server at `localhost:4321` |
| `pnpm build` | Build your production site to `./dist/` |
| `pnpm preview` | Preview your build locally, before deploying |
| `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` |
| `pnpm astro -- --help` | Get help using the Astro CLI |

45
astro.config.mjs Normal file
View File

@@ -0,0 +1,45 @@
// @ts-check
import { defineConfig } from "astro/config";
// Integrations
import sitemap from "@astrojs/sitemap";
import svelte from "@astrojs/svelte";
import AutoImport from "astro-auto-import";
import mdx from "@astrojs/mdx";
// Plugins
import tailwindcss from "@tailwindcss/vite";
import expressiveCode from "astro-expressive-code";
// https://astro.build/config
export default defineConfig({
site: "https://example.com",
integrations: [
// https://docs.astro.build/en/guides/integrations-guide/sitemap/
sitemap(),
// https://docs.astro.build/en/guides/integrations-guide/svelte/
svelte(),
// https://expressive-code.com/
expressiveCode({
defaultProps: { wrap: true },
themes: ["dark-plus", "github-dark"],
}),
// https://github.com/delucis/astro-auto-import/tree/main/packages/astro-auto-import
AutoImport({
imports: [
{
// The following translates to:
// import componentSet from "@components/mdx/index";
"@components/mdx/index": [["default", "componentSet"]],
},
],
}),
// IMPORTANT: MUST BE LAST INTEGRATION
// https://docs.astro.build/en/guides/integrations-guide/mdx/
mdx(),
],
vite: {
plugins: [tailwindcss()],
},
});

1087
bun.lock Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "parke.dev",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^4.2.0",
"@astrojs/rss": "^4.0.11",
"@astrojs/sitemap": "^3.2.1",
"@astrojs/svelte": "^7.0.6",
"@tailwindcss/vite": "^4.0.0",
"astro": "^5.5.2",
"astro-auto-import": "^0.4.4",
"astro-expressive-code": "^0.41.0",
"simple-icons": "^15.21.0",
"svelte": "^5.39.10",
"tailwindcss": "^4.0.0",
"typescript": "^5.8.2"
},
"devDependencies": {
"@lucide/svelte": "^0.552.0",
"@skeletonlabs/skeleton": "next",
"@skeletonlabs/skeleton-svelte": "next",
"@tailwindcss/forms": "^0.5.10"
}
}

BIN
public/IMG_9580.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
public/IMG_9586.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

9
public/favicon.svg Normal file
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

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Avatar } from "@skeletonlabs/skeleton-svelte";
type Props = {
image: string;
name: string;
};
const { image, name }: Props = $props();
const initials = name
.split(" ")
.map((name) => name[0])
.join("");
</script>
<Avatar class="size-16">
<Avatar.Image src={image} alt={name} />
<Avatar.Fallback>{initials}</Avatar.Fallback>
</Avatar>

View File

@@ -0,0 +1,73 @@
---
// Import the global.css file here so that it is included on
// all pages through the use of the <BaseHead /> component.
import "@styles/global.css";
import { SITE_TITLE } from "@src/consts";
interface Props {
title: string;
description: string;
author: string;
image?: string;
}
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const {
title,
description,
author,
image = "/blog-placeholder-1.jpg",
} = Astro.props;
---
<!-- Global Metadata -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="sitemap" href="/sitemap-index.xml" />
<link
rel="alternate"
type="application/rss+xml"
title={SITE_TITLE}
href={new URL("rss.xml", Astro.site)}
/>
<meta name="generator" content={Astro.generator} />
<!-- Font preloads -->
<link
rel="preload"
href="/fonts/atkinson-regular.woff"
as="font"
type="font/woff"
crossorigin
/>
<link
rel="preload"
href="/fonts/atkinson-bold.woff"
as="font"
type="font/woff"
crossorigin
/>
<!-- Canonical URL -->
<link rel="canonical" href={canonicalURL} />
<!-- Primary Meta Tags -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={new URL(image, Astro.url)} />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={Astro.url} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={new URL(image, Astro.url)} />

View File

@@ -0,0 +1,28 @@
---
import { socialLinks } from "@src/data/social-links";
const currentYear = new Date().getFullYear();
---
<footer class="section-padding border-surface-500/10 border-t py-5">
<div class="container mx-auto flex flex-col items-center gap-5">
<div class="flex space-x-4">
{
socialLinks.map((link) => (
<a
class="btn btn-sm hover:preset-tonal-tertiary"
href={link.url}
target={link.target}
rel="noreferrer"
title={link.title}
>
{link.title}
</a>
))
}
</div>
<p class="text-xs opacity-50">
© Copyright {currentYear}. All rights reserved.
</p>
</div>
</footer>

View File

@@ -0,0 +1,17 @@
---
interface Props {
date: Date;
}
const { date } = Astro.props;
---
<time datetime={date.toISOString()}>
{
date.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
</time>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
// Skeleton
import { AppBar } from "@skeletonlabs/skeleton-svelte";
// Icons
import IconLogo from "@lucide/svelte/icons/rocket";
// Utils
import { SITE_TITLE } from "@src/consts";
// For best results limit to 3-5 options max
const navigation = [
{ label: "Blog", href: "/" },
{ label: "Posts", href: "/posts" },
];
</script>
<header
class="sticky top-0 z-10 border-b border-surface-100-900 bg-surface-50-950/80 backdrop-blur-xl"
>
<AppBar class="bg-transparent container mx-auto">
<AppBar.Toolbar class="grid-cols-[auto_1fr_auto] gap-10">
<AppBar.Lead>
<a
href="/"
class="btn-icon md:btn-icon-lg from-secondary-500 to-tertiary-500 rounded-full bg-linear-to-tr text-white"
title={SITE_TITLE}
>
<IconLogo />
</a>
</AppBar.Lead>
<AppBar.Headline>
<nav class="flex gap-2">
{#each navigation as route}
<a class="btn hover:preset-tonal-tertiary" href={route.href}>
{route.label}
</a>
{/each}
</nav>
</AppBar.Headline>
</AppBar.Toolbar>
</AppBar>
</header>

12
src/components/Icon.astro Normal file
View File

@@ -0,0 +1,12 @@
---
export interface Props {
icon: string;
class?: string;
}
const { icon, ...attributes } = Astro.props as Props;
/* @vite-ignore */
const { default: innerHTML } = await import(`/src/svg/${icon}.svg?raw`);
---
<Fragment {...attributes} set:html={innerHTML} />

View File

@@ -0,0 +1,153 @@
<script lang="ts">
import { onMount } from "svelte";
const texts = ["OpenAPI", "Food", "AI", "Cats", "Developer Tools"];
type Slot = {
id: string;
content: string; // "old\n\nnew" or just "char"
index: number; // 0 → show old row, 1 → show new row
changed: boolean;
position: number; // slot position (0..longestLength-1)
};
let currentIndex = $state(0);
let currentText = $state(texts[0] ?? "");
let previousText = $state("");
let slots = $state<Slot[]>([]);
let animationKey = $state(0);
// longest string length across all texts
const longestLength = Math.max(...texts.map((t) => t.length));
// timing & easing (very similar to your example)
const charDuration = 450;
const intervalInMs = `${charDuration}ms`;
const ease = "cubic-bezier(1, 0, 0, 1)";
const baseDelay = 40; // global offset
const perLetterDelay = 25; // stagger between letters
function updateSlots(newText: string, oldText: string) {
const next: Slot[] = [];
const newLen = newText.length;
const oldLen = oldText.length;
// center each word inside the fixed board
const newOffset = Math.floor((longestLength - newLen) / 2);
const oldOffset = Math.floor((longestLength - oldLen) / 2);
for (let pos = 0; pos < longestLength; pos++) {
const newIdx = pos - newOffset;
const oldIdx = pos - oldOffset;
const newChar = newIdx >= 0 && newIdx < newLen ? newText[newIdx] : " ";
const oldChar =
oldText && oldIdx >= 0 && oldIdx < oldLen ? oldText[oldIdx] : " ";
// only animate if we have an old string and the char actually changed
const changed = !!oldText && newChar !== oldChar;
const content = changed
? `${oldChar || " "}\n\n${newChar || " "}`
: newChar || " ";
next.push({
id: `${pos}-${newChar}-${animationKey}`,
content,
index: 0, // always start at row 0 (old char)
changed,
position: pos,
});
}
slots = next;
// Dont animate the very first render
if (!oldText) return;
// Kick the index to 1 on the next frame so CSS can animate top → smooth scroll
requestAnimationFrame(() => {
slots = slots.map((slot) =>
slot.changed && slot.content.includes("\n\n")
? { ...slot, index: 1 }
: slot
);
});
}
// Rebuild slots whenever the text changes
$effect(() => {
updateSlots(currentText, previousText);
});
onMount(() => {
if (texts.length <= 1) return;
const tick = () => {
setTimeout(() => {
previousText = currentText;
currentIndex = (currentIndex + 1) % texts.length;
currentText = texts[currentIndex];
animationKey++;
tick();
}, 3000);
};
tick();
});
</script>
<div class="ticker" aria-live="polite">
{#each slots as slot (slot.id)}
<span class="slot">
<span
class="slot-inner"
style="
--index: {slot.index};
--interval: {intervalInMs};
--ease: {ease};
--delay: {baseDelay + slot.position * perLetterDelay}ms;
"
>
<span>{slot.content}</span>
</span>
</span>
{/each}
</div>
<style>
.ticker {
display: inline-flex;
align-items: center;
justify-content: center;
font-family: monospace;
/* font-size: clamp(2rem, 6vw, 4rem); */
gap: 0.08em; /* spacing between letters */
/* optional: make it obvious this is fixed width */
/* border: 1px dashed red; */
}
.slot {
display: inline-block;
position: relative;
line-height: 1em;
height: 1em;
}
.slot-inner {
height: 1em;
display: inline-block;
overflow-y: hidden;
}
/* sliding-text trick, per letter */
.slot-inner > span {
white-space: pre;
position: relative;
height: 100%;
top: calc(var(--index) * -2em); /* 0 → old row, 1 → new row */
transition: top var(--interval) var(--ease);
transition-delay: var(--delay);
}
</style>

View File

@@ -0,0 +1,5 @@
---
const props = Astro.props;
---
<a class="anchor" {...props}><slot /></a>

View File

@@ -0,0 +1,5 @@
---
const props = Astro.props;
---
<blockquote class="blockquote" {...props}><slot /></blockquote>

View File

@@ -0,0 +1,5 @@
---
const props = Astro.props;
---
<code class="code" {...props}><slot /></code>

View File

@@ -0,0 +1,5 @@
---
const props = Astro.props;
---
<h1 class="h1" {...props}><slot /></h1>

View File

@@ -0,0 +1,5 @@
---
const props = Astro.props;
---
<h2 class="h2 scroll-header" {...props}><slot /></h2>

View File

@@ -0,0 +1,5 @@
---
const props = Astro.props;
---
<h3 class="h3 scroll-header" {...props}><slot /></h3>

View File

@@ -0,0 +1,5 @@
---
const props = Astro.props;
---
<h3 class="h4 scroll-header" {...props}><slot /></h3>

View File

@@ -0,0 +1,5 @@
---
const props = Astro.props;
---
<h5 class="h5 scroll-header" {...props}><slot /></h5>

View File

@@ -0,0 +1,5 @@
---
const props = Astro.props;
---
<h6 class="h6 scroll-header" {...props}><slot /></h6>

View File

@@ -0,0 +1,5 @@
---
const props = Astro.props;
---
<hr class="hr" {...props} />

View File

@@ -0,0 +1,7 @@
---
const props = Astro.props;
---
<ol class="translate-x-5 list-outside list-decimal space-y-2" {...props}>
<slot />
</ol>

View File

@@ -0,0 +1,7 @@
---
const props = Astro.props;
---
<ul class="translate-x-5 list-outside list-disc space-y-2" {...props}>
<slot />
</ul>

View File

@@ -0,0 +1,9 @@
---
const props = Astro.props;
---
<div class="table-wrap">
<table class="table" {...props}>
<slot />
</table>
</div>

View File

@@ -0,0 +1,35 @@
// MDX Replacement Components
// https://docs.astro.build/en/guides/markdown-content/#assigning-custom-components-to-html-elements
// https://mdxjs.com/table-of-components/
import Heading1 from '@components/mdx/Heading1.astro';
import Heading2 from '@components/mdx/Heading2.astro';
import Heading3 from '@components/mdx/Heading3.astro';
import Heading4 from '@components/mdx/Heading4.astro';
import Heading5 from '@components/mdx/Heading5.astro';
import Heading6 from '@components/mdx/Heading6.astro';
import Anchor from '@components/mdx/Anchor.astro';
import Blockquote from '@components/mdx/Blockquote.astro';
import ListUnordered from '@components/mdx/ListUnordered.astro';
import ListOrdered from '@components/mdx/ListOrdered.astro';
import Code from '@components/mdx/Code.astro';
import HorzRule from '@components/mdx/HorzRule.astro';
import Table from '@components/mdx/Table.astro';
const componentSet = {
h1: Heading1,
h2: Heading2,
h3: Heading3,
h4: Heading4,
h5: Heading5,
h6: Heading6,
a: Anchor,
blockquote: Blockquote,
ul: ListUnordered,
ol: ListOrdered,
code: Code,
hr: HorzRule,
table: Table
};
export default componentSet;

6
src/consts.ts Normal file
View File

@@ -0,0 +1,6 @@
// Place any global data in this file.
// You can import this data from anywhere in your site by using the `import` keyword.
export const SITE_TITLE = "Blog";
export const SITE_DESCRIPTION = "Welcome to my blog!";
export const SITE_AUTHOR = "Luke Parke";

38
src/content.config.ts Normal file
View File

@@ -0,0 +1,38 @@
import { glob } from "astro/loaders";
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
// Load Markdown and MDX files in the `src/content/blog/` directory.
loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
// Type-check frontmatter using a schema
schema: z.object({
title: z.string(),
description: z.string(),
author: z.string().optional().default("luke-parke"),
topic: z.string().optional(),
tags: z.array(z.string()).optional(),
// Transform string to Date object
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
}),
});
const authors = defineCollection({
// Load Markdown and MDX files in the `src/content/authors/` directory.
loader: glob({ base: "./src/content/authors", pattern: "**/*.{md,mdx}" }),
// Type-check frontmatter using a schema
schema: z.object({
id: z.string(),
name: z.string(),
title: z.string(),
portrait: z.string(),
// bio: z.string().optional(),
location: z.string(),
website: z.string(),
blusky: z.string(),
email: z.string(),
}),
});
export const collections = { blog, authors };

View File

@@ -0,0 +1,10 @@
---
id: "luke-parke"
name: Luke Parke
title: Software Engineer
portrait: "IMG_9586.jpg"
location: "Austin, Texas"
website: "https://parke.dev"
blusky: "@lukehagar"
email: luke@parke.dev
---

View File

@@ -0,0 +1,46 @@
---
title: "Coolify is the best thing for Homelabs since Fiber"
description: "Deploying self hosted services with Coolify"
topic: "Development"
tags: ["Coolify", "Homelab", "SelfHosting", "WebDev"]
author: "luke-parke"
pubDate: "Nov 14 2025"
---
I've been using [Coolify](https://coolify.io) in my Homelab for a couple years now, and I have never enjoyed self hosting this much before.
Gone are the days of manually managing all of your subdomains and certificates in [Cloudflare](https://thehomelab.wiki/books/dns-reverse-proxy/page/setup-your-domain-with-cloudflare) and [Nginx Proxy Manager](https://nginxproxymanager.com), just setup a wildcard, and Coolify will handle the rest.
But it doesn't just handle SSL and domain management — it's a full open-source PaaS (Platform as a Service) that makes deploying apps, databases, and services effortless.
## Why Coolify?
With Coolify you can:
- **Deploy any app or service** — from static sites and APIs to full-stack apps built with Next.js, Remix, Nuxt, or SvelteKit. You can even spin up databases, WordPress, Ghost, Plausible Analytics, and more in just a couple clicks.
- **Run it anywhere** — your own VPS, Raspberry Pi, Hetzner, AWS, or even a laptop. If it supports Docker, Coolify can manage it.
- **Push to deploy** — connect a GitHub, GitLab, or Bitbucket repo and every push can auto-deploy. Pair it with pull request previews and you've got a smooth workflow for testing before merging.
- **Stay secure automatically** — free Let's Encrypt SSL for all your custom domains, with automatic renewals.
- **Keep your data safe** — one-click database backups to any S3-compatible storage.
- **Collaborate with ease** — invite teammates, assign roles, and share access safely.
And Coolify is fully self-hosted. That means **no vendor lock-in**, full control of your data, and the ability to move servers or providers whenever you like.
## Performance and Experience
Performance has been excellent in my Homelab — deployments finish fast, the real-time terminal is responsive, and monitoring tools keep me aware of server health and disk usage at a glance.
I have deployed a number of personal sites, projects, and other services through it for domains I own.
Some examples:
- [LukeHagar.com](https://lukehagar.com)
- [OpenAPI Definition Generator](https://oas-def-gen.lukehagar.com)
- [OpenAPI.gg](https://openapi.gg)
- [pypistats.dev](https://pypistats.dev)
If you're looking for a way to simplify your self-hosting setup without giving up control, I can't recommend Coolify enough.
It's been rock solid for me, and I'm genuinely excited to see where the project goes as it continues to evolve (Direct Multi-Server support is confirmed on the roadmap 👀).
👉 Check it out here: [https://coolify.io](https://coolify.io)

View File

@@ -0,0 +1,48 @@
---
title: "Easier deployment of SvelteKit node servers with docker"
description: "How to deploy SvelteKit node servers with docker"
topic: "Development"
tags: ["SvelteKit", "Svelte", "Docker", "Node.js"]
author: "luke-parke"
pubDate: "Nov 14 2025"
---
export const components = componentSet;
When you are NOT deploying to a nice and simple platform like Vercel, you may be doing things a little more manually.
For SvelteKit that is often really easy, tooling like nixpacks allows for a pretty much seamless deployment of most any application anywhere.
I was working on a simple little test app to explore the functionality of how Coolify and SvelteKit both handles subdomains and I was getting a node version mismatch error due to a dep requiring one of a few specific node versions.
Due to this I had to go the route of deploying with Docker instead, so here is a bare minimum Dockerfile for SvelteKit should anyone need one in the future.
```dockerfile
# ---- Build stage ----
FROM node:24-alpine AS build
WORKDIR /app
# Install dependencies (cache-friendly)
COPY package.json package-lock.json ./
RUN npm ci
# Copy source and build
COPY . .
RUN npm run build
# ---- Run stage ----
FROM node:24-alpine AS run
WORKDIR /app
# Copy the built app
COPY --from=build /app/package.json ./
COPY --from=build /app/build ./build
EXPOSE 3000
CMD ["node", "build"]
```
Cheers

23
src/data/social-links.ts Normal file
View File

@@ -0,0 +1,23 @@
export type SocialLink = {
title: "Bluesky" | "GitHub" | "LinkedIn";
url: string;
target?: "_blank";
};
export const socialLinks: SocialLink[] = [
{
title: "Bluesky",
url: "https://bsky.app/profile/lukehagar.com",
target: "_blank",
},
{
title: "GitHub",
url: "https://github.com/lukehagar",
target: "_blank",
},
{
title: "LinkedIn",
url: "https://linkedin.com/in/lukehagar",
target: "_blank",
},
];

126
src/layouts/BlogPost.astro Normal file
View File

@@ -0,0 +1,126 @@
---
import type { CollectionEntry } from "astro:content";
import LayoutRoot from "@layouts/LayoutRoot.astro";
import FormattedDate from "@components/FormattedDate.astro";
import Avatar from "@components/Avatar.svelte";
import { getEntry } from "astro:content";
type BlogMetaData = CollectionEntry<"blog">["data"];
interface Props extends BlogMetaData {
content: string;
}
const { title, description, pubDate, updatedDate, heroImage, content, author } =
Astro.props;
// https://dev.to/michaelburrows/calculate-the-estimated-reading-time-of-an-article-using-javascript-2k9l
function readingTime() {
const text = content;
const wpm = 150;
const words = text.trim().split(/\s+/).length;
const time = Math.ceil(words / wpm);
const minutePlural = time > 1 ? "s" : "";
return `${time} min${minutePlural}`;
}
const authorEntry = (await getEntry("authors", author || "luke-parke"))!.data;
---
<LayoutRoot
title={title}
description={description}
author={author}
pubDate={new Date()}
>
<article>
<!-- Header -->
<header class="p-5 md:p-10">
<div class="container mx-auto">
<h1 class="h1">{title}</h1>
</div>
</header>
<hr class="hr container mx-auto" />
<!-- Metadata -->
<section class="p-4 md:p-10">
<div class="container mx-auto">
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 md:gap-20"
>
<!-- Description -->
<div class="space-y-2">
<p>{description}</p>
</div>
<!-- Author -->
<div class="space-y-2">
<p class="text-xs">Authors</p>
<div class="grid grid-cols-1">
<p class="text-2xl font-bold">
<div class="flex items-center gap-2">
<Avatar
client:load
image={authorEntry?.portrait}
name={authorEntry?.name}
/>
<div class="space-y-1">
<p class="text-lg font-bold">{authorEntry?.name}</p>
<p class="text-xs opacity-75">
{authorEntry?.title}
</p>
</div>
</div>
</p>
</div>
</div>
<!-- Published -->
<div class="space-y-2">
<p class="text-xs">Published</p>
<time class="text-2xl font-bold">
<FormattedDate date={pubDate} />
{
updatedDate && (
<div class="text-xs opacity-75">
Last updated on <FormattedDate date={updatedDate} />
</div>
)
}
</time>
</div>
<!-- Reading Time -->
<div class="space-y-2">
<p class="text-xs">Reading Time</p>
<p class="text-2xl font-bold">
{readingTime()}
</p>
</div>
</div>
</div>
</section>
<!-- Hero Banner -->
{
heroImage && (
<section class="preset-filled-surface-950-50 p-5 md:p-10">
<div class="container mx-auto flex justify-center items-center">
{/* Replace `w-ull` with `max-w-full` if you prefer smaller images */}
<img
width={960}
height={480}
src={heroImage}
alt={title}
class="bg-surface-500 aspect-video w-full rounded-container overflow-hidden"
/>
</div>
</section>
)
}
<!-- Slot -->
<div class="container mx-auto space-y-8 px-4 py-10">
<slot />
</div>
</article>
</LayoutRoot>

View File

@@ -0,0 +1,33 @@
---
import type { CollectionEntry } from "astro:content";
import { SITE_TITLE, SITE_DESCRIPTION, SITE_AUTHOR } from "@src/consts";
import BaseHead from "@components/BaseHead.astro";
import Header from "@components/Header.svelte";
import Footer from "@components/Footer.astro";
type Props = CollectionEntry<"blog">["data"];
const { title, description, author } = Astro.props;
---
<!doctype html>
<html lang="en" data-theme="cerberus" class="dark">
<head>
<BaseHead
title={title || SITE_TITLE}
description={description || SITE_DESCRIPTION}
author={author || SITE_AUTHOR}
/>
</head>
<body>
<div class="grid min-h-screen grid-rows-[auto_1fr_auto]">
<Header client:load />
<main>
<slot />
</main>
<Footer />
</div>
</body>
</html>

212
src/pages/index.astro Normal file
View File

@@ -0,0 +1,212 @@
---
import LayoutRoot from "@layouts/LayoutRoot.astro";
import { getCollection } from "astro:content";
import FormattedDate from "@components/FormattedDate.astro";
import Scroller from "@components/Scroller.svelte";
import LinkedIn from "@svg/linkedin.svg";
import Bluesky from "@svg/bluesky.svg";
import GitHub from "@svg/github.svg";
import { socialLinks } from "@src/data/social-links";
const posts = (await getCollection("blog")).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
const authors = (await getCollection("authors")).sort(
// Alphabetical by id
(a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
);
const iconMap = {
Bluesky,
GitHub,
LinkedIn,
} as const;
const socialLinksWithIcons = socialLinks.map((link) => ({
...link,
Icon: iconMap[link.title],
}));
---
<LayoutRoot title="" description="" author="" pubDate={new Date()}>
<div>
<!-- DEBUG -->
<!-- <pre class="pre">{JSON.stringify(posts, null, 2)}</pre> -->
<!-- Header -->
<header class="flex justify-center items-center px-4 py-5 md:py-10">
<!-- Responsive Title -->
<!-- NOTE: you may need to adjust the font-size based on the number of characters -->
<!-- https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text -->
<div
class="flex flex-col gap-6 text-4xl sm:text-6xl xl:text-8xl 2xl:text-9xl justify-center uppercase font-mono font-bold"
>
<p class="text-center">A Blog about</p>
<Scroller client:load />
</div>
</header>
<hr class="hr container mx-auto" />
<!-- Featured -->
<section class="p-4 md:py-10">
<div class="container mx-auto grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- The Top Post -->
<a
class="card border border-surface-100-900 hover:preset-tonal overflow-hidden"
href={`/blog/${posts[0].id}/`}
>
{
posts[0].data.heroImage && (
<img
class="w-full aspect-video rounded-container"
src={posts[0].data.heroImage}
alt="Portfolio"
/>
)
}
<article class="p-4 space-y-4">
<time class="text-xs opacity-75">
<FormattedDate date={posts[0].data.pubDate} />
</time>
<h2 class="h2">{posts[0].data.title}</h2>
<p class="opacity-75">{posts[0].data.description}</p>
</article>
</a>
<!-- Additional Featured Posts -->
<div class="grid grid-cols-1 gap-4 place-content-start">
<!-- Loop Posts 2-4 -->
{
posts.slice(1, 4).map((post) => (
<a
class="card border border-surface-100-900 hover:preset-tonal overflow-hidden"
href={`/blog/${post.id}/`}
>
<article
class={`grid grid-cols-1 ${post.data.heroImage ? "sm:grid-cols-[280px_1fr]" : undefined}`}
>
{post.data.heroImage && (
<img
class="w-full aspect-video"
src={post.data.heroImage}
alt="Portfolio"
/>
)}
<div class="space-y-2 p-4">
<time class="text-xs opacity-75">
<FormattedDate date={post.data.pubDate} />
</time>
<h2 class="h2">{post.data.title}</h2>
<p class="opacity-75">{posts[0].data.description}</p>
</div>
</article>
</a>
))
}
</div>
</div>
</section>
<!-- Latest -->
<section class="preset-filled-surface-950-50 p-5 md:p-10">
<div class="container mx-auto space-y-4 md:space-y-10">
<h2 class="h3">Latest Posts</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{
posts.slice(1, 5).map((post) => (
<a
class="card border border-surface-900-100 hover:bg-surface-900-100 overflow-hidden"
href={`/blog/${post.id}/`}
>
{post.data.heroImage && (
<img
class="w-full aspect-video"
src={post.data.heroImage}
alt="Portfolio"
/>
)}
<article class="p-4 space-y-2">
<time class="block text-base">
<FormattedDate date={post.data.pubDate} />
</time>
<h3 class="h3">{post.data.title}</h3>
<p class="opacity-75">{posts[0].data.description}</p>
</article>
</a>
))
}
</div>
</div>
</section>
<!-- Authors / Categories -->
<section class="preset-filled-primary-500 p-5 md:p-10">
<div class="container mx-auto">
<div
class="grid grid-cols-1 lg:grid-cols-[1fr_2fr] items-center gap-10 lg:gap-20"
>
<!-- Categories -->
<div class="space-y-4">
<h2 class="h2">Who am I?</h2>
<p class="">
Hi, I'm Luke Parke
<br />
I am a husband and father, a native Austinite, and a software engineer.
<br />
<br />
And a few other things too:
<ul class="list-inside list-disc space-y-2">
<li>A lover of Svelte and SvelteKit</li>
<li>A fan of OpenAPI</li>
<li>A user of Dokploy</li>
<li>And a massive nerd</li>
</ul>
<br />
<p>Feel free to follow me on your platform of choice:</p>
<div class="flex items-center gap-2 divide-x divide-black">
{
socialLinksWithIcons.map(({ Icon, ...link }) => (
<a
class="flex items-center gap-2 px-2"
href={link.url}
target={link.target}
rel="noreferrer"
>
{Icon && <Icon class="size-6 fill-white" />}
{link.title}
</a>
))
}
</div>
</p>
</div>
<!-- Authors -->
<div class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-10">
{
authors.map((author) => (
<a
class="text-center space-y-2"
href={author.data.website}
target="_blank"
>
<img
class="w-full rounded-full mx-auto overflow-hidden"
src={author.data.portrait}
alt={author.data.id}
/>
<div>
<h4 class="h2 md:h3">{author.data.name}</h4>
<p class="opacity-75">{author.data.title}</p>
</div>
</a>
))
}
</div>
</div>
</div>
</div>
</section>
</div>
</LayoutRoot>

View File

@@ -0,0 +1,21 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import BlogPost from "@layouts/BlogPost.astro";
import { render } from "astro:content";
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { slug: post.id },
props: post,
}));
}
type Props = CollectionEntry<"blog">;
const post = Astro.props;
const { Content } = await render(post);
---
<BlogPost {...post.data} content={post.body ?? ""}>
<Content />
</BlogPost>

View File

@@ -0,0 +1,70 @@
---
import LayoutRoot from "@layouts/LayoutRoot.astro";
import { getCollection } from "astro:content";
import FormattedDate from "@components/FormattedDate.astro";
const posts = (await getCollection("blog")).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---
<LayoutRoot title="" description="" author="luke-parke" pubDate={new Date()}>
<div>
<!-- Header -->
<header class="flex justify-center items-center px-4 py-5 md:py-10">
<!-- https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text -->
<svg
class="container mx-auto w-full"
viewBox="0 0 1750 280"
xmlns="http://www.w3.org/2000/svg"
>
<text
font-size={300}
x="50%"
y="50%"
dominant-baseline="central"
text-anchor="middle"
class="fill-surface-950-50 uppercase font-bold"
>
ALL POSTS
</text>
</svg>
</header>
<hr class="hr container mx-auto" />
<!-- Articles -->
<nav
class="container mx-auto grid grid-cols-1 lg:grid-cols-2 gap-4 px-4 md:px-0 py-4 md:py-10"
>
{
posts.map((post) => (
<a
href={`/blog/${post.id}/`}
class="card border border-surface-100-900 hover:preset-tonal overflow-hidden"
>
{post.data.heroImage && (
<img
width={720}
height={360}
src={post.data.heroImage}
alt={post.data.title}
class="w-full"
/>
)}
<article class="grid grid-cols-1 gap-4 p-4">
<div class="space-y-4">
<p class="text-xs opacity-75">
<FormattedDate date={post.data.pubDate} />
</p>
<h2 class="h2">{post.data.title}</h2>
<p class="opacity-75">{post.data.description}</p>
</div>
</article>
</a>
))
}
</nav>
</div>
</LayoutRoot>

16
src/pages/rss.xml.js Normal file
View File

@@ -0,0 +1,16 @@
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
import { SITE_TITLE, SITE_DESCRIPTION } from "@src/consts";
export async function GET(context) {
const posts = await getCollection("blog");
return rss({
title: SITE_TITLE,
description: SITE_DESCRIPTION,
site: context.site,
items: posts.map((post) => ({
...post.data,
link: `/blog/${post.id}/`,
})),
});
}

16
src/styles/global.css Normal file
View File

@@ -0,0 +1,16 @@
@import "tailwindcss";
@plugin '@tailwindcss/forms';
@import "@skeletonlabs/skeleton";
@import "@skeletonlabs/skeleton/themes/cerberus";
@import '@skeletonlabs/skeleton-svelte';
/* Theme Overrides --- */
[data-theme="cerberus"] {
--text-scaling: 1;
--radius-base: 0.25rem;
--radius-container: 0.25rem;
--color-surface-950: oklch(0% 0 none);
}

3
src/svg/bluesky.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="9.9966 9.6552 580.0034 510.6799" xml:space="preserve">
<path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/>
</svg>

After

Width:  |  Height:  |  Size: 774 B

3
src/svg/github.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg height="800px" width="800px" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg" xml:space="preserve">
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" />
</svg>

After

Width:  |  Height:  |  Size: 1002 B

12
src/svg/linkedin.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 382 382" xml:space="preserve">
<path d="M347.445,0H34.555C15.471,0,0,15.471,0,34.555v312.889C0,366.529,15.471,382,34.555,382h312.889
C366.529,382,382,366.529,382,347.444V34.555C382,15.471,366.529,0,347.445,0z M118.207,329.844c0,5.554-4.502,10.056-10.056,10.056
H65.345c-5.554,0-10.056-4.502-10.056-10.056V150.403c0-5.554,4.502-10.056,10.056-10.056h42.806
c5.554,0,10.056,4.502,10.056,10.056V329.844z M86.748,123.432c-22.459,0-40.666-18.207-40.666-40.666S64.289,42.1,86.748,42.1
s40.666,18.207,40.666,40.666S109.208,123.432,86.748,123.432z M341.91,330.654c0,5.106-4.14,9.246-9.246,9.246H286.73
c-5.106,0-9.246-4.14-9.246-9.246v-84.168c0-12.556,3.683-55.021-32.813-55.021c-28.309,0-34.051,29.066-35.204,42.11v97.079
c0,5.106-4.139,9.246-9.246,9.246h-44.426c-5.106,0-9.246-4.14-9.246-9.246V149.593c0-5.106,4.14-9.246,9.246-9.246h44.426
c5.106,0,9.246,4.14,9.246,9.246v15.655c10.497-15.753,26.097-27.912,59.312-27.912c73.552,0,73.131,68.716,73.131,106.472
L341.91,330.654L341.91,330.654z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

5
svelte.config.js Normal file
View File

@@ -0,0 +1,5 @@
import { vitePreprocess } from '@astrojs/svelte';
export default {
preprocess: vitePreprocess(),
}

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"],
"compilerOptions": {
"moduleResolution": "bundler",
"strictNullChecks": true,
"baseUrl": ".",
"paths": {
"@src/*": ["src/*"],
"@layouts/*": ["src/layouts/*"],
"@components/*": ["src/components/*"],
"@content/*": ["src/content/*"],
"@styles/*": ["src/styles/*"],
"@svg/*": ["src/svg/*"]
}
}
}