First commit for universe

This commit is contained in:
Justin Edmund 2025-05-26 06:44:27 -07:00
parent e4e6610fee
commit bfd03cda87
26 changed files with 900 additions and 11 deletions

66
CLAUDE.md Normal file
View file

@ -0,0 +1,66 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
**Start development server:**
```bash
npm run dev
```
**Build for production:**
```bash
npm run build
```
**Type checking and linting:**
```bash
npm run check # Type check with svelte-check
npm run lint # Check formatting and linting
npm run format # Auto-format code with prettier
```
**Preview production build:**
```bash
npm run preview
```
## Architecture Overview
This is a SvelteKit personal portfolio site for @jedmund that integrates with multiple external APIs to display real-time data about music listening habits and gaming activity.
### Key Architecture Components
**API Integration Layer** (`src/routes/api/`)
- **Redis caching**: Shared Redis client (`redis-client.ts`) used across API routes for caching external API responses
- **External APIs**: Last.fm (music), Steam (games), PSN (PlayStation games), Giant Bomb (game metadata)
- **Data enrichment**: Last.fm data is enhanced with iTunes artwork and Giant Bomb metadata
**Frontend Structure**
- **Component-based**: Reusable Svelte components in `$lib/components/`
- **Page composition**: Main page (`+page.svelte`) composed of multiple `Page` components with different content sections
- **Data loading**: Server-side data fetching in `+page.ts` with error handling
**Styling System**
- **SCSS-based**: Global variables, fonts, themes automatically imported via Vite config
- **Asset management**: SVG icons and illustrations with automatic processing and alias imports
### Key Aliases (svelte.config.js)
- `$components``src/lib/components`
- `$icons``src/assets/icons`
- `$illos``src/assets/illos`
- `$styles``src/styles`
### Environment Dependencies
- Requires `LASTFM_API_KEY` and `REDIS_URL` environment variables
- Uses Node.js adapter for deployment

View file

@ -1,8 +1,8 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
import js from '@eslint/js'
import ts from 'typescript-eslint'
import svelte from 'eslint-plugin-svelte'
import prettier from 'eslint-config-prettier'
import globals from 'globals'
/** @type {import('eslint').Linter.FlatConfig[]} */
export default [
@ -30,4 +30,4 @@ export default [
{
ignores: ['build/', '.svelte-kit/', 'dist/']
}
];
]

122
package-lock.json generated
View file

@ -13,7 +13,9 @@
"@types/steamapi": "^2.2.5",
"dotenv": "^16.4.5",
"giantbombing-api": "^1.0.4",
"gray-matter": "^4.0.3",
"ioredis": "^5.4.1",
"marked": "^15.0.12",
"node-itunes-search": "^1.2.3",
"psn-api": "github:jedmund/psn-api",
"redis": "^4.7.0",
@ -2471,6 +2473,19 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
@ -2533,6 +2548,18 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
@ -2881,6 +2908,43 @@
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"dev": true
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"license": "MIT",
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/gray-matter/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/gray-matter/node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
@ -3069,6 +3133,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -3261,6 +3334,15 @@
"json-buffer": "3.0.1"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@ -3348,6 +3430,18 @@
"@jridgewell/sourcemap-codec": "^1.4.15"
}
},
"node_modules/marked": {
"version": "15.0.12",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mdn-data": {
"version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
@ -4196,6 +4290,19 @@
"node": ">=14.0.0"
}
},
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/semver": {
"version": "7.6.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
@ -4288,6 +4395,12 @@
"node": ">=0.10.0"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/sshpk": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
@ -4421,6 +4534,15 @@
"node": ">=8"
}
},
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",

View file

@ -43,7 +43,9 @@
"@types/steamapi": "^2.2.5",
"dotenv": "^16.4.5",
"giantbombing-api": "^1.0.4",
"gray-matter": "^4.0.3",
"ioredis": "^5.4.1",
"marked": "^15.0.12",
"node-itunes-search": "^1.2.3",
"psn-api": "github:jedmund/psn-api",
"redis": "^4.7.0",

2
src/app.d.ts vendored
View file

@ -10,4 +10,4 @@ declare global {
}
}
export {};
export {}

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M17.067 2.544C17.8009 2.75935 18.0308 3.68511 17.4834 4.21918L16.6819 5.00175C16.6195 5.06266 16.5738 5.1387 16.5499 5.22256L15.3478 9.45881C15.2919 9.6565 15.3639 9.86682 15.5211 9.99916C17.5805 11.7326 18.538 14.5803 17.7317 17.329C16.6434 21.0387 12.7539 23.1637 9.04422 22.0754C5.33457 20.9871 3.20954 17.0976 4.29783 13.3879C5.11503 10.6024 7.51165 8.70987 10.2256 8.40086C10.4344 8.37709 10.6146 8.23634 10.674 8.03472L11.899 3.86933C11.9255 3.77931 11.926 3.68337 11.9007 3.59301L11.6042 2.53614C11.3922 1.77984 12.0956 1.08554 12.8493 1.30664L17.067 2.544ZM15.4326 15.9971C15.5185 15.4516 15.0307 15.0293 14.4796 15.0652L7.49528 15.5187C6.94416 15.5546 6.51471 16.0375 6.67067 16.5673C7.2519 18.5414 9.13946 19.9216 11.279 19.7827C13.4189 19.6436 15.112 18.0301 15.4326 15.9971Z"/>
</svg>

After

Width:  |  Height:  |  Size: 890 B

View file

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M11.2299 5.99489L10.2978 3.12574C9.8848 1.85434 8.0806 1.87018 7.68997 3.14863L6.82721 5.97228C6.69024 6.42056 6.33317 6.76742 5.88111 6.89134L2.99869 7.68143C1.65766 8.04902 1.65766 9.95097 2.99869 10.3186L5.88111 11.1087C6.33317 11.2326 6.69024 11.5794 6.82721 12.0277L7.68997 14.8514C8.0806 16.1298 9.8848 16.1457 10.2978 14.8743L11.2299 12.0051C11.3714 11.5695 11.7216 11.2337 12.1627 11.1106L15.0073 10.3169C16.3403 9.94495 16.3403 8.05505 15.0073 7.6831L12.1627 6.88939C11.7216 6.76629 11.3714 6.43048 11.2299 5.99489Z"/>
</svg>

After

Width:  |  Height:  |  Size: 628 B

View file

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M2.90458 4.27439L5.03512 16.1109C5.22989 17.193 6.72825 17.3263 7.11093 16.2955L8.65655 12.1326C8.84013 11.6381 9.30501 11.3042 9.83221 11.2881L14.0645 11.1589C15.1441 11.1259 15.5123 9.70402 14.5844 9.15119L4.52063 3.15504C3.72483 2.68089 2.74048 3.3627 2.90458 4.27439Z" />
</svg>

After

Width:  |  Height:  |  Size: 376 B

View file

@ -25,6 +25,7 @@ $unit-20x: $unit * 20;
/* Page properties
* -------------------------------------------------------------------------- */
$page-corner-radius: $unit;
$card-corner-radius: $unit-3x;
$page-top-margin: $unit-6x;
@ -86,6 +87,20 @@ $grey-color: #f0f0f0;
$image-border-color: rgba(0, 0, 0, 0.03);
/* Shadows
* -------------------------------------------------------------------------- */
$card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
$card-shadow-hover: 0 4px 16px rgba(0, 0, 0, 0.12);
/* Pill colors
* -------------------------------------------------------------------------- */
$work-bg: #ffcdc5;
$work-color: #d0290d;
$universe-bg: #ffebc5;
$universe-color: #b97d14;
$labs-bg: #c5eaff;
$labs-color: #1482c1;
$facebook-color: #3b5998;
$twitter-color: #55acee;
$instagram-color: #3f729b;

View file

@ -0,0 +1,52 @@
<script lang="ts">
import Avatar from './Avatar.svelte'
import SegmentedController from './SegmentedController.svelte'
</script>
<header class="site-header">
<div class="header-content">
<a href="/" class="header-link" aria-label="@jedmund">
<Avatar />
</a>
<SegmentedController />
</div>
</header>
<style lang="scss">
.site-header {
display: flex;
justify-content: center;
padding: $unit-4x 0;
margin-bottom: $unit-2x;
}
.header-content {
display: flex;
align-items: center;
gap: $unit-3x;
}
.header-link {
display: flex;
align-items: center;
text-decoration: none;
height: 52px; // Reduced by 4px for optical balance
:global(.face-container) {
height: 52px;
width: 52px;
}
:global(svg) {
height: 100%;
width: 100%;
transition: transform 0.2s ease;
}
&:hover {
:global(svg) {
transform: scale(1.05);
}
}
}
</style>

View file

@ -0,0 +1,84 @@
<script lang="ts">
import type { ComponentType } from 'svelte'
let {
icon,
text,
href,
active = false,
variant = 'default'
}: {
icon: ComponentType
text: string
href?: string
active?: boolean
variant?: 'work' | 'universe' | 'default'
} = $props()
</script>
<a {href} class="pill {variant}" class:active>
<svelte:component this={icon} />
<span>{text}</span>
</a>
<style lang="scss">
.pill {
display: flex;
align-items: center;
gap: 4px;
padding: 10px 12px;
border-radius: 100px;
text-decoration: none;
color: $grey-20; // #666
font-size: 1rem;
font-weight: 400;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
transition: all 0.2s ease;
:global(svg) {
width: 20px;
height: 20px;
flex-shrink: 0;
fill: currentColor;
}
// Work variant
&.work {
&:hover,
&.active {
background: $work-bg;
color: $work-color;
:global(svg) {
fill: $work-color;
}
}
}
// Universe variant
&.universe {
&:hover,
&.active {
background: $universe-bg;
color: $universe-color;
:global(svg) {
fill: $universe-color;
}
}
}
// Default variant (Labs)
&.default {
&:hover,
&.active {
background: $labs-bg;
color: $labs-color;
:global(svg) {
fill: $labs-color;
}
}
}
}
</style>

View file

@ -0,0 +1,170 @@
<script lang="ts">
import type { Post } from '$lib/posts'
let { post }: { post: Post } = $props()
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})
}
</script>
<article class="post-content {post.type}">
<header class="post-header">
{#if post.title}
<h1 class="post-title">{post.title}</h1>
{/if}
<time class="post-date" datetime={post.date}>
{formatDate(post.date)}
</time>
</header>
<div class="post-body">
{@html post.content}
</div>
<footer class="post-footer">
<a href="/blog" class="back-link">← Back to all posts</a>
</footer>
</article>
<style lang="scss">
.post-content {
max-width: 784px;
margin: 0 auto;
&.note {
.post-body {
font-size: 1.1rem;
}
}
}
.post-header {
margin-bottom: $unit-5x;
}
.post-title {
margin: 0 0 $unit-2x;
font-size: 2rem;
font-weight: 600;
color: $grey-20;
line-height: 1.2;
}
.post-date {
display: block;
font-size: 0.9rem;
color: $grey-40;
font-weight: 400;
}
.post-body {
color: $grey-20;
line-height: 1.6;
:global(h2) {
margin: $unit-4x 0 $unit-2x;
font-size: 1.5rem;
font-weight: 600;
color: $grey-20;
}
:global(h3) {
margin: $unit-3x 0 $unit-2x;
font-size: 1.2rem;
font-weight: 600;
color: $grey-20;
}
:global(p) {
margin: 0 0 $unit-3x;
}
:global(ul),
:global(ol) {
margin: 0 0 $unit-3x;
padding-left: $unit-3x;
}
:global(ul li),
:global(ol li) {
margin-bottom: $unit;
}
:global(blockquote) {
margin: $unit-3x 0;
padding-left: $unit-3x;
border-left: 3px solid $grey-80;
color: $grey-40;
font-style: italic;
}
:global(code) {
background: $grey-90;
padding: 2px 6px;
border-radius: 3px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.9em;
}
:global(pre) {
background: $grey-90;
padding: $unit-2x;
border-radius: $unit;
overflow-x: auto;
margin: 0 0 $unit-3x;
}
:global(pre code) {
background: none;
padding: 0;
}
:global(a) {
color: $red-60;
text-decoration: none;
transition: all 0.2s ease;
}
:global(a:hover) {
text-decoration: underline;
text-decoration-style: wavy;
text-underline-offset: 0.15em;
}
:global(hr) {
border: none;
border-top: 1px solid $grey-80;
margin: $unit-4x 0;
}
:global(em) {
font-style: italic;
color: $grey-40;
}
}
.post-footer {
margin-top: $unit-6x;
padding-top: $unit-4x;
border-top: 1px solid $grey-80;
}
.back-link {
color: $red-60;
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
&:hover {
text-decoration: underline;
text-decoration-style: wavy;
text-underline-offset: 0.15em;
}
}
</style>

View file

@ -0,0 +1,79 @@
<script lang="ts">
import type { Post } from '$lib/posts'
let { post }: { post: Post } = $props()
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
</script>
<article class="post-item {post.type}">
<a href="/blog/{post.slug}" class="post-link">
{#if post.title}
<h2 class="post-title">{post.title}</h2>
{/if}
<p class="post-excerpt">{post.excerpt}</p>
<time class="post-date" datetime={post.date}>
{formatDate(post.date)}
</time>
</a>
</article>
<style lang="scss">
.post-item {
max-width: 700px;
margin: 0 auto;
&.note {
.post-excerpt {
font-size: 1rem;
}
}
}
.post-link {
display: block;
text-decoration: none;
color: inherit;
padding: $unit-3x;
background: $grey-100;
border-radius: $card-corner-radius;
transition: all 0.2s ease;
&:hover {
// Hover styles can be added here if needed
}
}
.post-title {
margin: 0 0 $unit;
font-size: 1.2rem;
font-weight: 600;
color: $red-60;
transition: color 0.2s ease;
}
.post-excerpt {
margin: 0 0 $unit-2x;
color: $grey-00;
font-size: 1rem;
line-height: 1.3;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
.post-date {
display: block;
font-size: 1rem;
color: $grey-40;
font-weight: 400;
}
</style>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import type { Post } from '$lib/posts'
import PostItem from './PostItem.svelte'
let { posts }: { posts: Post[] } = $props()
</script>
<div class="post-list">
{#if posts && posts.length > 0}
{#each posts as post}
<PostItem {post} />
{/each}
{:else}
<p>No posts found.</p>
{/if}
</div>
<style lang="scss">
.post-list {
display: flex;
flex-direction: column;
gap: $unit-3x;
}
</style>

View file

@ -0,0 +1,27 @@
<script lang="ts">
import Pill from './Pill.svelte'
import WorkIcon from '$icons/work.svg'
import LabsIcon from '$icons/labs.svg'
import UniverseIcon from '$icons/universe.svg'
import { page } from '$app/stores'
$: currentPath = $page.url.pathname
</script>
<nav class="segmented-controller">
<Pill icon={WorkIcon} text="Work" href="/" active={currentPath === '/'} variant="work" />
<Pill icon={LabsIcon} text="Labs" href="#" active={false} variant="default" />
<Pill icon={UniverseIcon} text="Universe" href="/blog" active={currentPath.startsWith('/blog')} variant="universe" />
</nav>
<style lang="scss">
.segmented-controller {
display: flex;
align-items: center;
gap: 4px;
background: $grey-100;
padding: $unit;
border-radius: 100px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
</style>

63
src/lib/posts.ts Normal file
View file

@ -0,0 +1,63 @@
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { marked } from 'marked'
export interface Post {
title?: string
type: 'note' | 'article' | 'image'
date: string
slug: string
published: boolean
content: string
excerpt?: string
images?: string[]
}
const postsDirectory = path.join(process.cwd(), 'src/lib/posts')
export async function getAllPosts(): Promise<Post[]> {
const fileNames = fs.readdirSync(postsDirectory)
const posts = fileNames
.filter((fileName) => fileName.endsWith('.md'))
.map((fileName) => {
const filePath = path.join(postsDirectory, fileName)
const fileContents = fs.readFileSync(filePath, 'utf8')
const { data, content } = matter(fileContents)
const slug = data.slug || fileName.replace(/\.md$/, '')
return {
...data,
slug,
content,
excerpt: getExcerpt(content, data.type)
} as Post
})
.filter((post) => post.published)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
return posts
}
export async function getPostBySlug(slug: string): Promise<Post | null> {
const posts = await getAllPosts()
const post = posts.find((p) => p.slug === slug)
if (!post) return null
return {
...post,
content: marked(post.content) as string
}
}
function getExcerpt(content: string, type: 'note' | 'article'): string {
const plainText = content.replace(/[#*`\[\]]/g, '').trim()
const maxLength = type === 'note' ? 280 : 160
if (plainText.length <= maxLength) return plainText
return plainText.substring(0, maxLength).trim() + '...'
}

View file

@ -0,0 +1,8 @@
---
type: 'note'
date: '2024-01-16T14:20:00Z'
slug: 'quick-thought-about-design-systems'
published: true
---
Been thinking about how the best design systems aren't the ones with the most components, but the ones with the clearest principles. You can have a thousand perfectly crafted components, but if your team doesn't understand the _why_ behind them, you've just built a very pretty prison.

View file

@ -0,0 +1,8 @@
---
type: 'note'
date: '2024-01-20T16:45:00Z'
slug: 'svg-animations-are-fun'
published: true
---
Just spent an hour making a squiggly line animation for my site. Could I have shipped three features in that time? Yes. Did the squiggly line spark more joy? Also yes. Not everything needs to be optimized for productivity.

View file

@ -0,0 +1,48 @@
---
title: "The Perfect Todo App Doesn't Exist"
type: 'article'
date: '2024-01-18T09:00:00Z'
slug: 'the-perfect-todo-app-doesnt-exist'
published: true
---
I've tried them all. Things, Todoist, Notion, Apple Reminders, pen and paper, sticky notes on my monitor. Each promises to be the solution to my productivity woes, and each eventually joins the graveyard of abandoned systems.
## The Cycle
It always starts the same way:
1. Discover new todo app
2. Feel rush of organizational dopamine
3. Migrate all tasks with excessive enthusiasm
4. Use religiously for 2-3 weeks
5. Gradually abandon as real life intrudes
6. Feel guilty about abandoned system
7. Return to step 1
## The Problem Isn't the Tool
After years of this cycle, I've realized something: the perfect todo app doesn't exist because the problem isn't the app. It's the expectation that any tool can magically transform us into productivity machines.
Real productivity isn't about finding the perfect system. It's about:
- Saying no to things that don't matter
- Being realistic about what you can accomplish
- Accepting that some days you'll get nothing done
- Understanding that busy isn't the same as productive
## What Works (For Me)
These days, I use a simple text file. One for today, one for this week, one for "someday maybe." No tags, no priorities, no due dates unless absolutely necessary. Just a list of things I'd like to do.
Some get done. Some don't. The world keeps spinning.
## The Real Secret
The perfect todo app doesn't exist because perfection doesn't exist. The best system is the one you'll actually use, even if it's just a piece of paper or a note on your phone.
Stop optimizing your system. Start doing the work.
---
_What's your relationship with todo apps? Have you found something that works, or are you still searching for the perfect system?_

View file

@ -0,0 +1,33 @@
---
title: 'Welcome to My Blog'
type: 'article'
date: '2024-01-15T10:30:00Z'
slug: 'welcome-to-my-blog'
published: true
---
After years of sharing my thoughts across various social platforms, I've decided to bring everything home. This blog will be a space for both quick notes and longer-form thoughts about design, development, and whatever else catches my interest.
## Why Now?
The internet feels different these days. Social media platforms come and go, APIs change, and our content gets scattered across dozens of services. I wanted a simple, permanent place for my writing that I control completely.
## What to Expect
You'll find a mix of content here:
- **Notes**: Quick thoughts, observations, and links I find interesting
- **Articles**: Deeper dives into topics I'm passionate about
- **Updates**: What I'm working on and thinking about
The design is intentionally minimal. No comments, no likes, no algorithms—just words on a page, the way blogs used to be.
## Technical Details
This blog is built with SvelteKit and uses markdown files for storage. It's fast, simple, and exactly what I need. No database, no CMS, just files in a folder.
Feel free to view source if you're curious about the implementation. Everything is open and straightforward.
---
_Thanks for reading. Here's to owning our own words._

View file

@ -1,4 +1,5 @@
<script lang="ts">
import Header from '$components/Header.svelte'
</script>
<svelte:head>
@ -11,6 +12,8 @@ user-scalable=no"
/>
</svelte:head>
<Header />
<main>
<slot />
</main>

View file

@ -1,6 +1,5 @@
<script lang="ts">
import Album from '$components/Album.svelte'
import Avatar from '$components/Avatar.svelte'
import Game from '$components/Game.svelte'
import MentionList from '$components/MentionList.svelte'
import Page from '$components/Page.svelte'
@ -16,9 +15,6 @@
<Page>
<svelte:fragment slot="header">
<h1 aria-label="@jedmund">
<Avatar />
</h1>
<h2 class="subheader">@jedmund is a software designer</h2>
</svelte:fragment>
@ -49,6 +45,9 @@
target="_blank">Carnegie Mellon University</a
> in 2011 with a Bachelors of Arts in Communication Design.
</p>
<p>
I occasionally write about design and development on my <a href="/blog">blog</a>.
</p>
</section>
</Page>
<Page>

View file

@ -0,0 +1,10 @@
import { getAllPosts } from '$lib/posts'
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async () => {
const posts = await getAllPosts()
return {
posts
}
}

View file

@ -0,0 +1,34 @@
<script lang="ts">
import Page from '$components/Page.svelte'
import PostList from '$components/PostList.svelte'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
</script>
<svelte:head>
<title>Blog - jedmund</title>
<meta name="description" content="Thoughts on design, development, and everything in between." />
</svelte:head>
<div class="blog-container">
<PostList posts={data.posts} />
</div>
<style lang="scss">
.blog-container {
max-width: 784px;
margin: $unit-6x auto;
padding: 0 $unit-5x;
@include breakpoint('phone') {
margin-top: $unit-3x;
margin-bottom: $unit-3x;
padding: 0 $unit-3x;
}
@include breakpoint('small-phone') {
padding: 0 $unit-2x;
}
}
</style>

View file

@ -0,0 +1,15 @@
import { getPostBySlug } from '$lib/posts'
import { error } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ params }) => {
const post = await getPostBySlug(params.slug)
if (!post) {
throw error(404, 'Post not found')
}
return {
post
}
}

View file

@ -0,0 +1,18 @@
<script lang="ts">
import Page from '$components/Page.svelte'
import PostContent from '$components/PostContent.svelte'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
const pageTitle = data.post.title || 'Blog post'
</script>
<svelte:head>
<title>{pageTitle} - jedmund</title>
<meta name="description" content={data.post.excerpt} />
</svelte:head>
<Page>
<PostContent post={data.post} />
</Page>