Merge pull request #38 from BradNut/development
Some checks failed
Run_Svelte_Integration_on_PRs / Run end-to-end tests (push) Has been cancelled
Run_Svelte_Unit_on_PRs / test (push) Has been cancelled

Development into master
This commit is contained in:
Bradley Shellnut 2025-08-28 23:11:28 +00:00 committed by GitHub
commit 87443420dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 3530 additions and 750 deletions

View file

@ -2,6 +2,10 @@ name: Run_Svelte_Check_on_PRs
on:
pull_request:
branches:
- master
- main
- development
workflow_dispatch:

View file

@ -0,0 +1,57 @@
name: Run_Svelte_Integration_on_PRs
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
workflow_dispatch:
jobs:
e2e-tests:
name: Run end-to-end tests
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: pnpm-setup
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Install playwright browsers
run: pnpm exec playwright install --with-deps
- name: Run tests
env:
WALLABAG_MAX_ARTICLES: ${{ secrets.WALLABAG_MAX_ARTICLES }}
WALLABAG_MAX_PAGES: ${{ secrets.WALLABAG_MAX_PAGES }}
WALLABAG_CLIENT_ID: ${{ secrets.WALLABAG_CLIENT_ID }}
WALLABAG_CLIENT_SECRET: ${{ secrets.WALLABAG_CLIENT_SECRET }}
WALLABAG_USERNAME: ${{ secrets.WALLABAG_USERNAME }}
WALLABAG_PASSWORD: ${{ secrets.WALLABAG_PASSWORD }}
WALLABAG_URL: ${{ secrets.WALLABAG_URL }}
BANDCAMP_USERNAME: ${{ secrets.BANDCAMP_USERNAME }}
PUBLIC_SITE_URL: ${{ secrets.PUBLIC_SITE_URL }}
PUBLIC_URL: ${{ secrets.PUBLIC_URL }}
PUBLIC_UMAMI_DO_NOT_TRACK: ${{ secrets.PUBLIC_UMAMI_DO_NOT_TRACK }}
PUBLIC_UMAMI_URL: ${{ secrets.PUBLIC_UMAMI_URL }}
PUBLIC_UMAMI_ID: ${{ secrets.PUBLIC_UMAMI_ID }}
PAGE_SIZE: ${{ secrets.PAGE_SIZE }}
USE_REDIS_CACHE: 'false'
REDIS_URI: ${{ secrets.REDIS_URI }}
run: pnpm test:integration
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

58
.github/workflows/svelte_unit.yml vendored Normal file
View file

@ -0,0 +1,58 @@
name: Run_Svelte_Unit_on_PRs
on:
push:
branches:
- master
- main
- development
pull_request:
branches:
- master
- main
- development
workflow_dispatch:
env:
WALLABAG_MAX_ARTICLES: ${{ secrets.WALLABAG_MAX_ARTICLES }}
WALLABAG_MAX_PAGES: ${{ secrets.WALLABAG_MAX_PAGES }}
WALLABAG_CLIENT_ID: ${{ secrets.WALLABAG_CLIENT_ID }}
WALLABAG_CLIENT_SECRET: ${{ secrets.WALLABAG_CLIENT_SECRET }}
WALLABAG_USERNAME: ${{ secrets.WALLABAG_USERNAME }}
WALLABAG_PASSWORD: ${{ secrets.WALLABAG_PASSWORD }}
WALLABAG_URL: ${{ secrets.WALLABAG_URL }}
BANDCAMP_USERNAME: ${{ secrets.BANDCAMP_USERNAME }}
PUBLIC_SITE_URL: ${{ secrets.PUBLIC_SITE_URL }}
PUBLIC_URL: ${{ secrets.PUBLIC_URL }}
PUBLIC_UMAMI_DO_NOT_TRACK: ${{ secrets.PUBLIC_UMAMI_DO_NOT_TRACK }}
PUBLIC_UMAMI_URL: ${{ secrets.PUBLIC_UMAMI_URL }}
PUBLIC_UMAMI_ID: ${{ secrets.PUBLIC_UMAMI_ID }}
PAGE_SIZE: ${{ secrets.PAGE_SIZE }}
USE_REDIS_CACHE: ${{ secrets.USE_REDIS_CACHE }}
REDIS_URI: ${{ secrets.REDIS_URI }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: pnpm-setup
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Execute unit tests
run: pnpm test:unit

4
.gitignore vendored
View file

@ -16,3 +16,7 @@ vite.config.ts.timestamp-*
# Sentry Config File
.sentryclirc
# Test Results
test-results
test-results/*

19
.storybook/main.ts Normal file
View file

@ -0,0 +1,19 @@
import type { StorybookConfig } from '@storybook/sveltekit';
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|ts|svelte)"],
addons: [
"@storybook/addon-svelte-csf",
"@chromatic-com/storybook",
"@storybook/addon-docs",
"@storybook/addon-a11y"
],
framework: {
name: "@storybook/sveltekit",
options: {},
},
core: {
disableTelemetry: true,
},
};
export default config;

16
.storybook/preview.ts Normal file
View file

@ -0,0 +1,16 @@
import '../src/styles/styles.pcss';
import type { Preview } from '@storybook/sveltekit';
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

3
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"workbench.colorTheme": "Dark+ (default dark)",
}

View file

@ -13,15 +13,21 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "biome lint --error-on-warnings .",
"format": "biome format --write .",
"storybook": "storybook dev -p 6006",
"test:integration": "playwright test",
"test:unit": "vitest"
},
"devDependencies": {
"@biomejs/biome": "^2.1.4",
"@internationalized/date": "^3.8.2",
"@playwright/test": "^1.54.2",
"@biomejs/biome": "^2.2.2",
"@chromatic-com/storybook": "^4.1.1",
"@internationalized/date": "^3.9.0",
"@playwright/test": "^1.55.0",
"@storybook/addon-a11y": "^9.1.3",
"@storybook/addon-docs": "^9.1.3",
"@storybook/addon-svelte-csf": "^5.0.8",
"@storybook/sveltekit": "^9.1.3",
"@sveltejs/enhanced-img": "^0.5.1",
"@sveltejs/kit": "^2.29.0",
"@sveltejs/kit": "^2.36.3",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@unpic/svelte": "^1.0.0",
"@zerodevx/svelte-img": "^2.1.2",
@ -31,10 +37,11 @@
"postcss-custom-media": "^11.0.6",
"postcss-import": "^16.1.1",
"postcss-load-config": "^6.0.1",
"postcss-preset-env": "^10.2.4",
"satori": "^0.12.2",
"postcss-preset-env": "^10.3.0",
"satori": "^0.16.2",
"satori-html": "^0.3.2",
"svelte": "^5.38.1",
"storybook": "^9.1.3",
"svelte": "^5.38.5",
"svelte-check": "^4.3.1",
"svelte-meta-tags": "^4.4.0",
"svelte-preprocess": "^6.0.3",
@ -48,12 +55,12 @@
},
"dependencies": {
"@resvg/resvg-js": "^2.6.2",
"@sveltejs/adapter-node": "^5.2.14",
"@vercel/og": "^0.6.8",
"bits-ui": "2.9.2",
"@sveltejs/adapter-node": "^5.3.1",
"@vercel/og": "^0.8.5",
"bits-ui": "2.9.4",
"flexsearch": "^0.8.205",
"ioredis": "^5.7.0",
"lucide-svelte": "^0.539.0",
"lucide-svelte": "^0.542.0",
"scrape-it": "^6.1.11",
"sharp": "^0.34.3",
"svelte-local-storage-store": "^0.6.4"

View file

@ -2,11 +2,31 @@ import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
webServer: {
command: 'pnpm run build && pnpm run preview',
port: 4173
command: 'pnpm -s exec svelte-kit sync && pnpm run build && pnpm run preview',
port: 4173,
timeout: 180_000,
reuseExistingServer: true
},
testDir: 'tests',
testMatch: /(.+\.)?(test|spec)\.[jt]s/
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
use: {
baseURL: 'http://localhost:4173'
},
// Run on main browsers: Chromium (Chrome), Firefox, WebKit (Safari)
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' }
},
{
name: 'firefox',
use: { browserName: 'firefox' }
},
{
name: 'webkit',
use: { browserName: 'webkit' }
}
]
};
export default config;

File diff suppressed because it is too large Load diff

14
src/ambient.d.ts vendored
View file

@ -1,6 +1,14 @@
// Stop warnings of all imports from your image assets directory.
declare module '$lib/assets/*' {
const image: Record<string, any>;
export default image;
// Enhanced images (?enhanced) provide a Picture for <enhanced:img>
declare module '$lib/assets/*?enhanced' {
import type { Picture } from '@sveltejs/enhanced-img';
const picture: Picture;
export default picture;
}
// Plain asset imports fallback to string URLs
declare module '$lib/assets/*' {
const src: string;
export default src;
}

View file

@ -1 +0,0 @@
/* Write your global styles here, in PostCSS syntax */

View file

@ -1,7 +0,0 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

145
src/lib/api.test.ts Normal file
View file

@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Hoisted mocks to satisfy Vitest's module mock hoisting
const hoisted = vi.hoisted(() => {
return {
redisMock: {
get: vi.fn(),
set: vi.fn(),
ttl: vi.fn(),
},
} as const;
});
// Mock env constants used by fetchArticlesApi
vi.mock('$env/static/private', () => ({
PAGE_SIZE: '10',
USE_REDIS_CACHE: 'true', // enable cache to test both hit/miss paths with stubs
WALLABAG_CLIENT_ID: 'client-id',
WALLABAG_CLIENT_SECRET: 'client-secret',
WALLABAG_PASSWORD: 'password',
WALLABAG_URL: 'https://wallabag.example',
WALLABAG_USERNAME: 'username',
}));
// Mock redis client so no real connection is used
vi.mock('$lib/server/redis', () => ({
redis: hoisted.redisMock,
}));
// Helper to mock global fetch responses
function makeJsonResponse<T>(data: T, headers: Record<string, string> = {}) {
return {
ok: true,
status: 200,
statusText: 'OK',
headers: { get: (k: string) => headers[k.toLowerCase()] ?? null },
json: async () => data,
} as const;
}
// Import after mocks are set up
import { fetchArticlesApi } from './api';
describe('fetchArticlesApi (unit, mocked)', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('fetches and maps articles on cache miss, then stores in redis', async () => {
// Cache miss setup
hoisted.redisMock.get.mockResolvedValueOnce(null);
hoisted.redisMock.ttl.mockResolvedValueOnce(0);
// Mock token fetch
const token = { access_token: 'access-token' } as const;
// Mock entries fetch
const wallabagResponse = {
_embedded: {
items: [
{
title: 'Great Post',
url: 'https://example.com/post',
domain_name: 'www.example.com',
hashed_url: 'hash123',
reading_time: 7,
preview_picture: 'https://example.com/img.jpg',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z',
archived_at: null,
tags: [{ slug: 'programming' }],
},
],
},
page: 1,
pages: 5,
total: 100,
limit: 10,
} as const;
const fetchMock = vi.fn(async (input: unknown) => {
const url = String(input);
if (url.endsWith('/oauth/v2/token')) {
return makeJsonResponse(token);
}
if (url.startsWith('https://wallabag.example/api/entries.json')) {
return makeJsonResponse(wallabagResponse, { 'cache-control': 'max-age=60' });
}
throw new Error('Unexpected fetch to ' + url);
});
// @ts-expect-error assign to global
global.fetch = fetchMock;
const result = await fetchArticlesApi('GET', 'entries', { page: '1', limit: '10' });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result.currentPage).toBe(1);
expect(result.totalPages).toBe(5);
expect(result.limit).toBe(10);
expect(result.totalArticles).toBe(100);
expect(result.cacheControl).toBe('max-age=60');
expect(result.articles.length).toBe(1);
const article = result.articles[0];
expect(article.title).toBe('Great Post');
expect(article.url).toBeInstanceOf(URL);
expect(article.url.hostname).toBe('example.com');
expect(article.domain_name).toBe('example.com');
// Stored in Redis with EX for 12 hours
expect(hoisted.redisMock.set).toHaveBeenCalled();
const setArgs = (hoisted.redisMock.set as unknown as { mock: { calls: unknown[][] } }).mock
.calls[0] as [string, string, 'EX', number];
expect(setArgs[0]).toContain('perPage=10');
expect(setArgs[0]).toContain('page=1');
expect(setArgs[2]).toBe('EX');
expect(setArgs[3]).toBe(43200);
});
it('returns cached response and cacheControl when redis has value (cache hit)', async () => {
const cached = {
articles: [],
currentPage: 2,
totalPages: 3,
limit: 10,
totalArticles: 20,
};
hoisted.redisMock.get.mockResolvedValueOnce(JSON.stringify(cached));
hoisted.redisMock.ttl.mockResolvedValueOnce(321);
const fetchMock = vi.fn();
// @ts-expect-error assign to global
global.fetch = fetchMock;
const result = await fetchArticlesApi('GET', 'entries', { page: '2', limit: '10' });
// No network calls on cache hit
expect(fetchMock).not.toHaveBeenCalled();
expect(result.currentPage).toBe(2);
expect(result.totalPages).toBe(3);
expect(result.limit).toBe(10);
expect(result.totalArticles).toBe(20);
expect(result.cacheControl).toBe('max-age=321');
});
});

View file

@ -0,0 +1,18 @@
<script module lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import Articles from "./Articles.svelte";
const { Story } = defineMeta({
title: "Components/Articles",
component: Articles,
tags: ["autodocs"],
args: {
data: { articles: [], totalArticles: 0, classes: [], compact: false },
},
argTypes: {
data: { control: "object" },
},
});
</script>
<Story name="Default" args={{ data: { articles: [], totalArticles: 0, classes: [], compact: false } }} />

View file

@ -62,7 +62,6 @@
title: `Link to ${article.title}`,
target: "_blank",
}}
iconData={{ iconClass: "center" }}
/>
</h3>
<p>{article.domain_name}</p>
@ -81,7 +80,7 @@
{/if}
</div>
{#if page.url.pathname === "/"}
<a class="moreArticles" href="/articles"
<a class="moreArticles" href="/articles/1"
>{`${totalArticles} more articles`} <ArrowRight /></a
>
{/if}

View file

@ -0,0 +1,13 @@
<script context="module" lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import ArticlesSkeleton from "./ArticlesSkeleton.svelte";
const { Story } = defineMeta({
title: "Components/ArticlesSkeleton",
component: ArticlesSkeleton,
tags: ["autodocs"],
args: { count: 6, classes: [] },
});
</script>
<Story name="Default" />

View file

@ -12,26 +12,22 @@
<article class={`card skeleton ${classes.join(" ")}`}>
<section>
<h3>
<span class="skeleton-text skeleton-title" aria-hidden="true"
<span class="skeleton-text skeleton-title"
>Loading article title...</span
>
</h3>
<p>
<span class="skeleton-text skeleton-domain" aria-hidden="true"
>Loading domain...</span
>
<span class="skeleton-text skeleton-domain">Loading domain...</span>
</p>
</section>
<section>
<p>
<span class="skeleton-text skeleton-reading" aria-hidden="true"
<span class="skeleton-text skeleton-reading"
>Loading reading time...</span
>
</p>
<p>
<span class="skeleton-text skeleton-tags" aria-hidden="true"
>Loading tags...</span
>
<span class="skeleton-text skeleton-tags">Loading tags...</span>
</p>
</section>
</article>

View file

@ -0,0 +1,39 @@
<script context="module" lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import type { Album } from "$lib/types/album";
import Bandcamp from "./Bandcamp.svelte";
import { sampleAlbum as baseAlbum } from "./BandcampAlbum.stories.svelte";
const sampleAlbums: Album[] = [
{
...baseAlbum,
title: "Album One",
artwork: "https://picsum.photos/230?1",
},
{
...baseAlbum,
title: "Album Two",
artwork: "https://picsum.photos/230?2",
},
{
...baseAlbum,
title: "Album Three",
artwork: "https://picsum.photos/230?3",
},
{
...baseAlbum,
title: "Album Four",
artwork: "https://picsum.photos/230?4",
},
];
const { Story } = defineMeta({
title: "Components/Bandcamp",
component: Bandcamp,
tags: ["autodocs"],
args: { albums: sampleAlbums },
argTypes: { albums: { control: "object" } },
});
</script>
<Story name="Default" />

View file

@ -1,6 +1,6 @@
<script lang="ts">
import type { Album } from "$lib/types/album";
import LazyImage from './LazyImage.svelte';
import BandcampAlbum from './BandcampAlbum.svelte';
const { albums }: { albums: Album[] } = $props();
const displayAlbums =
@ -22,26 +22,7 @@
<h2>Currently listening to:</h2>
<div class="albumsStyles">
{#each displayAlbums as album}
<div class="albumStyles">
<figure>
<a
title={`Link to ${album.title} by ${album.artist}`}
target="_blank"
href={album.url}
rel="noreferrer"
>
<LazyImage clazz="album-artwork" src={album.src} alt={`Album art for ${album.title}`} />
</a>
</figure>
<a
target="_blank"
href={album.url}
rel="noreferrer"
>
<h3>{album.title.length > 20 ? `${album.title.slice(0, 20)}...` : album.title}</h3>
<h3>{album.artist}</h3>
</a>
</div>
<BandcampAlbum {album} />
{/each}
</div>
</div>
@ -83,36 +64,7 @@
border: 3px solid var(--darkGrey);
}
/* ::-webkit-scrollbar {
width: 12px;
} */
/* scrollbar-width: thin;
scrollbar-color: var(--lightGrey) var(--darkGrey);
::-webkit-scrollbar-track {
background: var(--darkGrey);
}
::-webkit-scrollbar-thumb {
background-color: var(--lightGrey);
border-radius: 6px;
border: 3px solid var(--darkGrey);
} */
grid-template-columns: minmax(230px, 1fr);
}
}
.albumStyles {
display: grid;
justify-content: center;
text-align: center;
& figure {
margin-left: auto;
margin-right: auto;
}
@media (max-width: 550px) {
font-size: 1rem;
align-items: center;
}
}
</style>

View file

@ -0,0 +1,30 @@
<script module lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import type { Album } from "$lib/types/album";
import BandcampAlbum from "./BandcampAlbum.svelte";
export const sampleAlbum: Album = {
title: "Album One",
artist: "Artist A",
url: "https://example.com",
artwork: "https://picsum.photos/230?1",
src: {
img: { src: "https://picsum.photos/230?1", w: 230, h: 230 },
sources: {
avif: "https://picsum.photos/230?1",
webp: "https://picsum.photos/230?1",
jpg: "https://picsum.photos/230?1",
},
},
};
const { Story } = defineMeta({
title: "Components/BandcampAlbum",
component: BandcampAlbum,
tags: ["autodocs"],
args: { album: sampleAlbum },
argTypes: { album: { control: "object" } },
});
</script>
<Story name="Default" />

View file

@ -0,0 +1,45 @@
<script lang="ts">
import type { Album } from "$lib/types/album";
import LazyImage from './LazyImage.svelte';
const { album }: { album: Album } = $props();
</script>
<div class="albumStyles">
<figure>
<a
title={`Link to ${album.title} by ${album.artist}`}
target="_blank"
href={album.url}
rel="noreferrer"
>
<LazyImage clazz="album-artwork" src={album.src} alt={`Album art for ${album.title}`} />
</a>
</figure>
<a
target="_blank"
href={album.url}
rel="noreferrer"
>
<h3>{album.title.length > 20 ? `${album.title.slice(0, 20)}...` : album.title}</h3>
<h3>{album.artist}</h3>
</a>
</div>
<style lang="postcss">
.albumStyles {
display: grid;
justify-content: center;
text-align: center;
& figure {
margin-left: auto;
margin-right: auto;
}
@media (max-width: 550px) {
font-size: 1rem;
align-items: center;
}
}
</style>

View file

@ -0,0 +1,40 @@
<script context="module" lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import ContactHub from "./ContactHub.svelte";
const defaultUserNames = {
x: "example_x",
blueSky: "example.bsky.social",
linkedIn: "example-linkedin",
github: "example-github",
email: "user@example.com",
};
const { Story } = defineMeta({
title: "Components/ContactHub",
component: ContactHub,
tags: ["autodocs"],
args: {
showBlueSky: true,
showEmail: true,
showGithub: true,
showLinkedIn: true,
showX: true,
userNames: defaultUserNames,
showText: true,
justify: true,
},
argTypes: {
showBlueSky: { control: "boolean" },
showEmail: { control: "boolean" },
showGithub: { control: "boolean" },
showLinkedIn: { control: "boolean" },
showX: { control: "boolean" },
showText: { control: "boolean" },
justify: { control: "boolean" },
userNames: { control: "object" },
},
});
</script>
<Story name="Default" />

View file

@ -1,12 +1,12 @@
<script lang="ts">
import { Mail } from "lucide-svelte";
import ExternalLink from "$lib/components/ExternalLink.svelte";
import {
blueSkyIcon,
gitHubIcon,
linkedInIcon,
xIcon,
} from "../util/logoIcons.svelte";
import ExternalLink from '$lib/components/ExternalLink.svelte';
interface Props {
showBlueSky?: boolean;
@ -37,36 +37,66 @@
<div class:justifyCenter={justify}>
{#if showX && userNames?.x}
<ExternalLink
linkData={{ href: `https://www.x.com/${userNames.x}`, ariaLabel: 'Contact through X', title: 'Contact through X', target: '_blank', clazz: "hub-icon x-contact" }}
iconData={{ type: 'svg', icon: xIcon, iconClass: 'center' }}
linkData={{
href: `https://www.x.com/${userNames.x}`,
ariaLabel: "Contact through X",
title: "Contact through X",
target: "_blank",
clazz: "hub-icon x-contact",
}}
iconData={{ type: "svg", icon: xIcon, iconClass: "center" }}
textData={{ showIcon: true }}
/>
{/if}
{#if showBlueSky && userNames?.blueSky}
<ExternalLink
linkData={{ href: `https://bsky.app/profile/${userNames.blueSky}`, ariaLabel: 'Contact through Bluesky', title: 'Contact through Bluesky', target: '_blank', clazz: "hub-icon bluesky-contact" }}
iconData={{ type: 'svg', icon: blueSkyIcon, iconClass: 'center' }}
linkData={{
href: `https://bsky.app/profile/${userNames.blueSky}`,
ariaLabel: "Contact through Bluesky",
title: "Contact through Bluesky",
target: "_blank",
clazz: "hub-icon bluesky-contact",
}}
iconData={{ type: "svg", icon: blueSkyIcon, iconClass: "center" }}
textData={{ showIcon: true }}
/>
{/if}
{#if showLinkedIn && userNames?.linkedIn}
<ExternalLink
linkData={{ href: `https://www.linkedin.com/in/${userNames.linkedIn}`, ariaLabel: 'Contact through LinkedIn', title: 'Contact through LinkedIn', target: '_blank', clazz: "hub-icon linkedIn-contact" }}
iconData={{ type: 'svg', icon: linkedInIcon, iconClass: 'center' }}
linkData={{
href: `https://www.linkedin.com/in/${userNames.linkedIn}`,
ariaLabel: "Contact through LinkedIn",
title: "Contact through LinkedIn",
target: "_blank",
clazz: "hub-icon linkedIn-contact",
}}
iconData={{ type: "svg", icon: linkedInIcon, iconClass: "center" }}
textData={{ showIcon: true }}
/>
{/if}
{#if showGithub && userNames?.github}
<ExternalLink
linkData={{ href: `https://www.github.com/${userNames.github}`, ariaLabel: 'Contact through Github', title: 'Contact through Github', target: '_blank', clazz: "hub-icon github-contact" }}
iconData={{ type: 'svg', icon: gitHubIcon, iconClass: 'center' }}
linkData={{
href: `https://www.github.com/${userNames.github}`,
ariaLabel: "Contact through Github",
title: "Contact through Github",
target: "_blank",
clazz: "hub-icon github-contact",
}}
iconData={{ type: "svg", icon: gitHubIcon, iconClass: "center" }}
textData={{ showIcon: true }}
/>
{/if}
{#if showEmail && userNames?.email}
<ExternalLink
linkData={{ href: `mailto:${userNames.email}`, ariaLabel: 'Contact by email', title: 'Contact by email', target: '_blank', clazz: "hub-icon email-contact" }}
iconData={{ type: 'icon', icon: Mail, iconClass: 'center' }}
linkData={{
href: `mailto:${userNames.email}`,
ariaLabel: "Contact by email",
title: "Contact by email",
target: "_blank",
clazz: "hub-icon email-contact",
}}
iconData={{ type: "icon", icon: Mail, iconClass: "center" }}
textData={{ showIcon: true }}
/>
{/if}

View file

@ -0,0 +1,29 @@
<script module lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import { blueSkyIcon } from "../util/logoIcons.svelte";
import ExternalLink from "./ExternalLink.svelte";
const { Story } = defineMeta({
title: "Components/ExternalLink",
component: ExternalLink,
tags: ["autodocs"],
args: {
linkData: {
href: "https://example.com",
ariaLabel: "Go to example.com",
title: "Example",
target: "_blank",
clazz: "hub-icon bluesky-contact",
},
textData: { text: "Visit Example", location: "left", showIcon: true },
iconData: { type: "svg", icon: blueSkyIcon, iconClass: "center" },
},
argTypes: {
linkData: { control: "object" },
textData: { control: "object" },
iconData: { control: "object" },
},
});
</script>
<Story name="Default" />

View file

@ -1,28 +1,31 @@
<script lang="ts">
import type { ExternalLinkType, LinkIconType } from '$lib/types/externalLinkTypes';
import { ExternalLink } from 'lucide-svelte';
import { ExternalLink } from 'lucide-svelte';
import type { ExternalLinkType, LinkIconType } from '$lib/types/externalLinkTypes';
const { iconData, linkData, textData }: ExternalLinkType = $props();
const { iconData = { type: 'icon', icon: ExternalLink }, linkData, textData }: ExternalLinkType = $props();
// Guarantee non-optional icon data for linkIcon()
const safeIconData: LinkIconType = iconData ?? { type: 'icon', icon: ExternalLink };
let textLocationClass = '';
if (textData?.location === 'top') {
let textLocationClass = '';
if (textData?.location === 'top') {
textLocationClass = 'text-top';
} else if (textData?.location === 'bottom') {
} else if (textData?.location === 'bottom') {
textLocationClass = 'text-bottom';
} else if (textData?.location === 'left') {
} else if (textData?.location === 'left') {
textLocationClass = 'text-left';
} else if (textData?.location === 'right') {
} else if (textData?.location === 'right') {
textLocationClass = 'text-right';
} else {
} else {
textLocationClass = 'text-left';
}
}
const linkDecoration =
const linkDecoration =
linkData?.textDecoration && linkData?.textDecoration === 'none' ? `text-decoration-${linkData?.textDecoration}` : 'text-decoration-underline';
const linkClass = `${linkData?.clazz || ''} ${textLocationClass} ${linkDecoration}`.trim();
const linkClass = `${linkData?.clazz || ''} ${textLocationClass} ${linkDecoration}`.trim();
</script>
{#snippet externalLink({ iconData, linkData, textData }: ExternalLinkType)}
{#snippet externalLink({ iconData = { type: 'icon', icon: ExternalLink }, linkData, textData }: ExternalLinkType)}
<a
class={linkClass}
aria-label={`Open ${linkData?.ariaLabel ?? linkData?.title ?? linkData?.href} externally`}
@ -35,7 +38,7 @@
{textData?.text}
{/if}
{#if textData?.showIcon}
{@render linkIcon(iconData ?? {})}
{@render linkIcon(safeIconData)}
{/if}
{#if textData?.location === "bottom" || (textData?.location === "right" && textData?.text)}
{textData?.text}
@ -44,7 +47,7 @@
{/snippet}
{#snippet linkIcon({ type, icon, iconClass }: LinkIconType)}
{#if type === "svg" && icon && typeof icon === 'function' && icon.length !== undefined}
{#if type === "svg" && icon && typeof icon === "function" && icon.length !== undefined}
<svg
style="width: 2.5rem; height: 2.5rem;"
class={iconClass ?? ""}
@ -52,14 +55,17 @@
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>
{linkData?.title ?? `Open ${linkData?.ariaLabel} externally`}
</title>
{@render (icon as any)()}
</svg>
{:else if type === "icon" && icon}
{@const Icon = icon}
<Icon />
<Icon><title>{linkData?.title ?? `Open ${linkData?.ariaLabel} externally`}</title></Icon>
{:else}
{@const Icon = ExternalLink}
<Icon />
<Icon><title>{linkData?.title ?? `Open ${linkData?.ariaLabel} externally`}</title></Icon>
{/if}
{/snippet}

View file

@ -0,0 +1,29 @@
<script context="module" lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import LazyImage from './LazyImage.svelte';
const sampleSrc = {
img: { src: 'https://picsum.photos/400/300', w: 400, h: 300 },
sources: {
avif: 'https://picsum.photos/400/300.avif',
webp: 'https://picsum.photos/400/300.webp',
jpg: 'https://picsum.photos/400/300.jpg'
}
};
const { Story } = defineMeta({
title: 'Components/LazyImage',
component: LazyImage,
tags: ['autodocs'],
args: { clazz: 'demo-image', src: sampleSrc, alt: 'Random image', style: '', loading: 'lazy' },
argTypes: {
clazz: { control: 'text' },
src: { control: 'object' },
alt: { control: 'text' },
style: { control: 'text' },
loading: { control: { type: 'select' }, options: ['lazy', 'eager'] }
}
});
</script>
<Story name="Default" />

View file

@ -0,0 +1,16 @@
<script module lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import Link from "./Link.svelte";
const { Story } = defineMeta({
title: "Components/Link",
component: Link,
tags: ["autodocs"],
});
</script>
<Story name="Default">
<Link href="/" ariaLabel="Example link">
<span>Go Home</span>
</Link>
</Story>

View file

@ -4,16 +4,10 @@
target?: string;
href: string;
ariaLabel: string;
children?: import('svelte').Snippet;
children?: import("svelte").Snippet;
}
let {
rel = '',
target = '',
href,
ariaLabel,
children
}: Props = $props();
let { rel = "", target = "", href, ariaLabel, children }: Props = $props();
</script>
<a aria-label={ariaLabel} {href} {rel} {target}>

View file

@ -0,0 +1,20 @@
<script context="module" lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Pagination from './Pagination.svelte';
const { Story } = defineMeta({
title: 'Components/Pagination',
component: Pagination,
tags: ['autodocs'],
args: { additionalClasses: '', pageSize: 10, totalCount: 50, currentPage: 1, base: '/page' },
argTypes: {
additionalClasses: { control: 'text' },
pageSize: { control: { type: 'number', min: 1, step: 1 } },
totalCount: { control: { type: 'number', min: 0, step: 1 } },
currentPage: { control: { type: 'number', min: 1, step: 1 } },
base: { control: 'text' }
}
});
</script>
<Story name="Default" />

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Pagination } from "bits-ui";
import { ChevronLeft, ChevronRight } from 'lucide-svelte';
import { ChevronLeft, ChevronRight } from "lucide-svelte";
import { goto } from "$app/navigation";
interface Props {
additionalClasses: string;
@ -12,33 +12,41 @@
base: string;
}
let {
additionalClasses,
pageSize,
totalCount,
currentPage,
base
}: Props = $props();
let { additionalClasses, pageSize, totalCount, currentPage, base }: Props = $props();
</script>
<Pagination.Root count={totalCount} perPage={pageSize} page={currentPage || 1} class={`${additionalClasses}`}
onPageChange={(page) => goto(`${base}/${page}`)}>
<Pagination.Root
count={totalCount}
perPage={pageSize}
page={currentPage || 1}
class={`${additionalClasses}`}
aria-label="Pagination"
onPageChange={(page) => goto(`${base}/${page}`)}
>
{#snippet children({ pages })}
<Pagination.PrevButton>
<Pagination.PrevButton aria-label="Previous page">
<ChevronLeft />
</Pagination.PrevButton>
{#each pages as page (page.key)}
{#if page.type === "ellipsis"}
<div class="ellipsis text-[15px] font-medium text-foreground-alt">...</div>
<div class="ellipsis text-[15px] font-medium text-foreground-alt">
...
</div>
{:else}
<Pagination.Page {page}>
<a href={`${base}/${page.value}`} data-sveltekit-preload-data="hover">
{#snippet child({ props })}
<button
{...props}
type="button"
aria-label={`Go to page ${page.value}`}
>
{page.value}
</a>
</button>
{/snippet}
</Pagination.Page>
{/if}
{/each}
<Pagination.NextButton>
<Pagination.NextButton aria-label="Next page">
<ChevronRight />
</Pagination.NextButton>
{/snippet}
@ -92,9 +100,7 @@
}
:global([data-selected]) {
a {
color: var(--shellYellow);
}
}
:global([data-pagination-root]) {
@ -112,11 +118,10 @@
:global([data-pagination-page]) {
padding: 1rem;
flex: 1;
border: 0;
border-right: 1px solid var(--grey);
background: transparent;
text-decoration: none;
a {
text-decoration: none;
}
cursor: pointer;
}
</style>

View file

@ -0,0 +1,14 @@
<script context="module" lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Tag from './Tag.svelte';
const { Story } = defineMeta({
title: 'Components/Tag',
component: Tag,
tags: ['autodocs'],
args: { name: 'JavaScript' },
argTypes: { name: { control: 'text' } }
});
</script>
<Story name="Default" />

View file

@ -0,0 +1,12 @@
<script context="module" lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import Footer from "./index.svelte";
const { Story } = defineMeta({
title: "Components/Footer",
component: Footer,
tags: ["autodocs"],
});
</script>
<Story name="Default" />

View file

@ -1,24 +1,30 @@
<script lang="ts">
import { page } from '$app/stores';
import ContactHub from '$lib/components/ContactHub.svelte';
const userNames = {
import { page } from '$app/state';
import ContactHub from '$lib/components/ContactHub.svelte';
const userNames = {
github: 'BradNut',
linkedIn: 'bradley-shellnut',
email: 'website[at]bradleyshellnut.com',
};
};
</script>
<footer>
<ContactHub showGithub showLinkedIn showEmail justify {userNames} />
<nav class="footer-list" aria-label="footer navigation">
<a class:active={$page.url.pathname === '/'} href="/">Home</a>
<a class:active={$page.url.pathname === '/about'} href="/about">About</a>
<a class:active={$page.url.pathname === '/portfolio'} href="/portfolio">Portfolio</a>
<a class:active={$page.url.pathname === '/uses'} href="/uses">Uses</a>
<a class:active={$page.url.pathname === '/privacy'} href="/privacy">Privacy</a>
<a class:active={$page.url.pathname === '/articles/1'} href="/articles">Favorite Articles</a>
<a class:active={page.url.pathname === "/"} href="/">Home</a>
<a class:active={page.url.pathname === "/about"} href="/about">About</a>
<a class:active={page.url.pathname === "/portfolio"} href="/portfolio"
>Portfolio</a
>
<a class:active={page.url.pathname === "/uses"} href="/uses">Uses</a>
<a class:active={page.url.pathname === "/privacy"} href="/privacy"
>Privacy</a
>
<a class:active={page.url.pathname === "/articles/1"} href="/articles/1"
>Favorite Articles</a
>
</nav>
<!-- <p className="center"> -->
<p>
Bradley Shellnut &copy; 2012 - {new Date().getFullYear()}
</p>

View file

@ -0,0 +1,12 @@
<script context="module" lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Header from './index.svelte';
const { Story } = defineMeta({
title: 'Components/Header',
component: Header,
tags: ['autodocs']
});
</script>
<Story name="Default" />

View file

@ -0,0 +1,12 @@
<script context="module" lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import Logo from "./index.svelte";
const { Story } = defineMeta({
title: "Components/Logo",
component: Logo,
tags: ["autodocs"],
});
</script>
<Story name="Default" />

View file

@ -1,27 +1,24 @@
<script lang="ts">
import beeIcon from '$lib/assets/images/bee.svg';
import shellIcon from '$lib/assets/images/shell.svg';
import nutIcon from '$lib/assets/images/hazelnut.svg';
import shellIcon from '$lib/assets/images/shell.svg';
// @ts-expect-error: Type 'Record<string, any>' is not assignable to type 'string'.ts(2322)
const bee: string = beeIcon;
// @ts-expect-error: Type 'Record<string, any>' is not assignable to type 'string'.ts(2322)
const shell: string = shellIcon;
// @ts-expect-error: Type 'Record<string, any>' is not assignable to type 'string'.ts(2322)
const nut: string = nutIcon;
</script>
<div>
<a href="/" class="center">
<img src={bee} alt="Bee Icon" width="30" height="30"/>
<img src={bee} alt="Bee Icon" width="30" height="30" />
<p>Bradley</p>
</a>
<a href="/" class="center">
<img src={shell} alt="Shell Icon" width="30" height="30"/>
<img src={shell} alt="Shell Icon" width="30" height="30" />
<p>Shell</p>
</a>
<a href="/" class="center">
<img src={nut} alt="Nut Icon" width="30" height="30"/>
<img src={nut} alt="Nut Icon" width="30" height="30" />
<p>Nut</p>
</a>
</div>
@ -39,7 +36,7 @@
--scale: 0;
& p::after {
content: '';
content: "";
position: absolute;
width: 100%;
height: 2px;

View file

@ -0,0 +1,12 @@
<script context="module" lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf";
import Nav from "./index.svelte";
const { Story } = defineMeta({
title: "Components/Nav",
component: Nav,
tags: ["autodocs"],
});
</script>
<Story name="Default" />

View file

@ -1,25 +1,25 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
</script>
<header aria-label="header navigation">
<nav>
<a href="/" class:active={$page.url.pathname === '/'}>Home</a>
<a href="/" class:active={page.url.pathname === '/'}>Home</a>
<a
href="/about"
class:active={$page.url.pathname === '/about'}
class:active={page.url.pathname === '/about'}
>
About
</a>
<a
href="/portfolio"
class:active={$page.url.pathname === '/portfolio'}
class:active={page.url.pathname === '/portfolio'}
>
Portfolio
</a>
<a
href="/uses"
class:active={$page.url.pathname === '/uses'}
class:active={page.url.pathname === '/uses'}
>
Uses
</a>

View file

@ -0,0 +1,30 @@
<script context="module" lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import SocialImageCard from './socialImageCard.svelte';
const { Story } = defineMeta({
title: 'Components/SocialImageCard',
component: SocialImageCard,
tags: ['autodocs'],
args: {
header: 'Bradley Shellnut',
page: 'Home',
image: '/b_shell_nut_favicon.png',
content: 'Welcome to my site',
width: 800,
height: 418,
url: 'https://bradleyshellnut.com'
},
argTypes: {
header: { control: 'text' },
page: { control: 'text' },
image: { control: 'text' },
content: { control: 'text' },
width: { control: { type: 'number', min: 100, step: 10 } },
height: { control: { type: 'number', min: 100, step: 10 } },
url: { control: 'text' }
}
});
</script>
<Story name="Default" />

View file

@ -1,8 +1,8 @@
import { Resvg } from '@resvg/resvg-js';
import satori from 'satori';
import { html as toReactNode } from 'satori-html';
import type { Component } from 'svelte';
import { render } from 'svelte/server';
import { Resvg } from '@resvg/resvg-js';
import { html as toReactNode } from 'satori-html';
import { dev } from '$app/environment';
import { read } from '$app/server';
@ -16,7 +16,8 @@ export async function componentToPng(component: Component, props: Record<string,
console.log('result', result);
const markup = toReactNode(`${result.body}${result.head}`);
const svg = await satori(markup, {
// Cast markup to any to satisfy satori's ReactNode expectation
const svg = await satori(markup as any, {
fonts: [
{
name: 'Fira Sans',

View file

@ -1,4 +1,28 @@
import { Redis } from 'ioredis';
import { REDIS_URI } from '$env/static/private';
import { REDIS_URI, USE_REDIS_CACHE } from '$env/static/private';
export const redis = new Redis(REDIS_URI);
type RedisLike = {
get: (key: string) => Promise<string | null>;
set: (key: string, value: string, mode?: 'EX', ttlSeconds?: number) => Promise<'OK'>;
ttl: (key: string) => Promise<number>;
};
function createStub(): RedisLike {
return {
async get() {
return null;
},
async set() {
// no-op stub returns OK to match ioredis contract
return 'OK' as const;
},
async ttl() {
return 0;
},
};
}
export const redis: RedisLike =
USE_REDIS_CACHE === 'true' && REDIS_URI
? (new Redis(REDIS_URI) as unknown as RedisLike)
: createStub();

View file

@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock private env used by the service
vi.mock('$env/static/private', () => ({
PAGE_SIZE: '10',
USE_REDIS_CACHE: 'true',
WALLABAG_CLIENT_ID: 'cid',
WALLABAG_CLIENT_SECRET: 'csecret',
WALLABAG_PASSWORD: 'pw',
WALLABAG_URL: 'https://example.com',
WALLABAG_USERNAME: 'user',
}));
// Mock redis client
const redisGet = vi.fn();
const redisSet = vi.fn();
const redisTtl = vi.fn();
vi.mock('$lib/server/redis', () => ({
redis: {
get: (key: string) => redisGet(key),
set: (key: string, value: string, mode: 'EX', seconds: number) => redisSet(key, value, mode, seconds),
ttl: (key: string) => redisTtl(key),
},
}));
import { fetchArticlesApi } from './articlesApi';
type MockResponse<T> = {
ok: boolean;
headers?: { get: (k: string) => string | null };
json: () => Promise<T>;
};
const makeWallabagResponse = () => ({
_embedded: {
items: [
{
title: 'Article 1',
url: 'https://example.com/a1',
domain_name: 'www.example.com',
hashed_url: 'hash1',
reading_time: 5,
preview_picture: 'https://example.com/img.jpg',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
archived_at: null,
tags: [{ id: 1, label: 'Programming', slug: 'programming' }],
},
],
},
page: 1,
pages: 1,
total: 1,
limit: 10,
});
const makeCachedResponse = () => ({
articles: [],
currentPage: 1,
totalPages: 1,
limit: 10,
totalArticles: 0,
cacheControl: 'max-age=60',
});
const originalFetch: typeof globalThis.fetch = globalThis.fetch;
beforeEach(() => {
vi.useFakeTimers();
redisGet.mockReset();
redisSet.mockReset();
redisTtl.mockReset();
});
afterEach(() => {
vi.useRealTimers();
globalThis.fetch = originalFetch;
});
describe('fetchArticlesApi', () => {
it('returns cached response with cacheControl on cache hit', async () => {
const cached = makeCachedResponse();
redisGet.mockResolvedValueOnce(JSON.stringify(cached));
redisTtl.mockResolvedValueOnce(60);
// fetch should not be called on cache hit
globalThis.fetch = vi.fn() as unknown as typeof globalThis.fetch;
const result = await fetchArticlesApi('get', 'fetchArticles', { page: '1', limit: '10' });
expect(result).toBeTruthy();
expect(result.cacheControl).toBe('max-age=60');
expect(redisGet).toHaveBeenCalledTimes(1);
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it('fetches from API and stores in cache on cache miss', async () => {
// Cache miss
redisGet.mockResolvedValueOnce(null);
// Mock fetch for auth and entries
const authResponse: MockResponse<{ access_token: string }> = {
ok: true,
json: async () => ({ access_token: 'token' }),
};
const entriesJson = makeWallabagResponse();
const pageResponse: MockResponse<ReturnType<typeof makeWallabagResponse>> = {
ok: true,
headers: { get: (k: string) => (k.toLowerCase() === 'cache-control' ? 'max-age=120' : null) },
json: async () => entriesJson,
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(authResponse) // oauth token
.mockResolvedValueOnce(pageResponse); // entries
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
const result = await fetchArticlesApi('get', 'fetchArticles', { page: '1', limit: '10' });
expect(result).toBeTruthy();
expect(result.cacheControl).toBe('max-age=120');
expect(result.articles.length).toBeGreaterThan(0);
expect(result.articles[0].domain_name).toBe('example.com');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(redisSet).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,186 @@
import intersect from 'just-intersect';
import {
PAGE_SIZE,
USE_REDIS_CACHE,
WALLABAG_CLIENT_ID,
WALLABAG_CLIENT_SECRET,
WALLABAG_PASSWORD,
WALLABAG_URL,
WALLABAG_USERNAME,
} from '$env/static/private';
import { redis } from '$lib/server/redis';
import type { Article, ArticlePageLoad, WallabagArticle } from '$lib/types/article';
import { ArticleTag } from '$lib/types/articleTag';
import type { PageQuery } from '../types/pageQuery';
// Normalize Wallabag base URL and derive endpoints robustly
const wallabagBase = new URL(WALLABAG_URL);
const tokenUrl = new URL('/oauth/v2/token', wallabagBase).toString();
const entriesUrl = new URL('/api/entries.json', wallabagBase).toString();
// Retry helper with exponential backoff
async function retryWithBackoff<T>(fn: () => Promise<T>, maxRetries = 3, baseDelay = 500): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (attempt === maxRetries) {
throw lastError;
}
// Exponential backoff: 500ms, 1s, 2s
const delay = baseDelay * 2 ** attempt;
console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}
export async function fetchArticlesApi(_method: string, _resource: string, queryParams: Record<string, string>) {
try {
let perPage = Number(queryParams?.limit);
if (perPage === undefined || perPage > 30 || perPage < 1) {
perPage = Number(PAGE_SIZE);
} else {
perPage = Number(queryParams?.limit);
}
const pageQuery: PageQuery = {
sort: 'updated',
perPage,
since: 0,
page: Number(queryParams?.page) || 1,
tags: 'programming',
content: 'metadata',
};
const entriesQueryParams = new URLSearchParams({
sort: pageQuery.sort,
perPage: `${pageQuery.perPage}`,
since: `${pageQuery.since}`,
page: `${pageQuery.page}`,
tags: pageQuery.tags,
content: pageQuery.content,
});
if (USE_REDIS_CACHE === 'true') {
const cacheKey = entriesQueryParams.toString();
const cached = await redis.get(cacheKey);
if (cached) {
// Cache hit, return cached payload with TTL-derived cache-control
const response = JSON.parse(cached);
const ttl = await redis.ttl(cacheKey);
return { ...response, cacheControl: `max-age=${ttl}` };
}
}
const authBody = {
grant_type: 'password',
client_id: WALLABAG_CLIENT_ID,
client_secret: WALLABAG_CLIENT_SECRET,
username: WALLABAG_USERNAME,
password: WALLABAG_PASSWORD,
};
const auth = await retryWithBackoff(async () => {
// Authenticate to Wallabag
const authResponse = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(authBody),
signal: AbortSignal.timeout(10000), // 10 second timeout
});
if (!authResponse.ok) {
throw new Error(`Auth failed: ${authResponse.status} ${authResponse.statusText}`);
}
return await authResponse.json();
});
const { wallabagResponse, cacheControl } = await retryWithBackoff(async () => {
const requestUrl = `${entriesUrl}?${entriesQueryParams}`;
// console.debug(`Fetching Wallabag entries: ${requestUrl}`);
const pageResponse = await fetch(requestUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${auth.access_token}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(15000), // 15 second timeout
});
if (!pageResponse.ok) {
// Log status only to avoid leaking headers/body
console.warn(`Wallabag entries request failed: ${pageResponse.status} ${pageResponse.statusText}`);
throw new Error(`API request failed: ${pageResponse.status} ${pageResponse.statusText}`);
}
const cacheControl = pageResponse.headers.get('cache-control') || 'no-cache';
const wallabagResponse = await pageResponse.json();
return { wallabagResponse, cacheControl };
});
const { _embedded: favoriteArticles, page, pages, total, limit } = wallabagResponse;
const articles: Article[] = [];
// Minimal, non-sensitive diagnostics
console.info(`Wallabag entries: page=${page} pages=${pages} total=${total} limit=${limit}`);
for (const article of favoriteArticles.items as WallabagArticle[]) {
const rawTags = article?.tags?.map((tag) => tag.slug);
if (intersect(rawTags, Object.values(ArticleTag))?.length > 0) {
const tags = rawTags.map((rawTag) => rawTag as unknown as ArticleTag);
articles.push({
tags,
title: article.title,
url: new URL(article.url),
domain_name: article.domain_name?.replace('www.', '') ?? '',
hashed_url: article.hashed_url,
reading_time: article.reading_time,
preview_picture: article.preview_picture,
created_at: new Date(article.created_at),
updated_at: new Date(article.updated_at),
archived_at: article.archived_at ? new Date(article.archived_at) : null,
});
}
}
const responseData: ArticlePageLoad = {
articles,
currentPage: page,
totalPages: pages,
limit,
totalArticles: total,
cacheControl,
};
if (USE_REDIS_CACHE === 'true' && responseData?.articles?.length > 0) {
const cacheKey = entriesQueryParams.toString();
console.log(`Storing in cache with key: ${cacheKey} for page ${page}`);
redis.set(cacheKey, JSON.stringify(responseData), 'EX', 43200);
}
return responseData;
} catch (error) {
console.error(`Error fetching articles for page ${queryParams?.page}:`, error);
// Return empty response on error to prevent app crash
const fallbackResponse: ArticlePageLoad = {
articles: [],
currentPage: Number(queryParams?.page) || 1,
totalPages: 0,
limit: Number(queryParams?.limit) || Number(PAGE_SIZE),
totalArticles: 0,
cacheControl: 'no-cache',
};
return fallbackResponse;
}
}

View file

@ -1,4 +1,3 @@
import type { Snippet } from 'svelte';
import type { Icon as IconType } from 'lucide-svelte';
export interface LinkTextType {
@ -13,7 +12,7 @@ export interface LinkDataType {
href: string;
title?: string;
ariaLabel: string;
clazz?: string | undefined;
clazz?: string;
textDecoration?: 'none' | 'underline' | 'line-through';
}
@ -23,8 +22,14 @@ export interface ExternalLinkType {
textData?: LinkTextType;
}
export interface LinkIconType {
type?: 'icon' | 'svg';
icon?: Snippet | typeof IconType;
iconClass?: string | undefined;
}
export type LinkIconType =
| {
type: 'svg';
icon: () => unknown;
iconClass?: string;
}
| {
type: 'icon';
icon: typeof IconType;
iconClass?: string;
};

View file

@ -1,17 +1,47 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
// Mock env to avoid relying on GitHub secrets and to disable Redis usage
vi.mock('$env/static/private', () => ({
BANDCAMP_USERNAME: 'testuser',
USE_REDIS_CACHE: 'false',
}));
// Stub Redis client so no real connection is attempted
vi.mock('$lib/server/redis', () => ({
redis: {
get: vi.fn(async () => null),
set: vi.fn(async () => 'OK'),
ttl: vi.fn(async () => 0),
},
}));
// Mock scrape-it to avoid real network calls to Bandcamp
vi.mock('scrape-it', () => ({
default: vi.fn(async () => ({
data: {
collectionItems: [
{
url: 'https://bandcamp.com/album/123',
artwork: 'https://img.bandcamp.com/art.jpg',
title: 'Test Album',
artist: 'Test Artist',
},
],
},
})),
}));
import { fetchBandcampAlbums } from './fetchBandcampAlbums';
describe('test fetchBandcampAlbums', () => {
it('fetches bandcamp albums', async () => {
describe('fetchBandcampAlbums (mocked)', () => {
it('returns albums from mocked scrape-it', async () => {
const albums = await fetchBandcampAlbums();
expect(albums).not.toBeNull();
expect(albums).toBeTruthy();
expect(albums?.length).toBeGreaterThan(0);
for (const album of albums) {
expect(album?.artist).toHaveLength;
expect(album?.artwork).toHaveLength;
expect(album?.title).toHaveLength;
expect(album?.url).toHaveLength;
}
const [album] = albums;
expect(album.artist).toBe('Test Artist');
expect(album.title).toBe('Test Album');
expect(album.url).toBe('https://bandcamp.com/album/123');
expect(album.artwork).toBe('https://img.bandcamp.com/art.jpg');
});
});

View file

@ -1,10 +1,10 @@
import { BANDCAMP_USERNAME, USE_REDIS_CACHE } from '$env/static/private';
import scrapeIt from 'scrape-it';
import type { ScrapeResult } from 'scrape-it';
import scrapeIt from 'scrape-it';
import { BANDCAMP_USERNAME, USE_REDIS_CACHE } from '$env/static/private';
import { redis } from '$lib/server/redis';
import type { Album, BandCampResults } from '../types/album';
export async function fetchBandcampAlbums() {
export async function fetchBandcampAlbums(): Promise<Album[] & { cacheControl?: string }> {
try {
if (USE_REDIS_CACHE === 'true') {
const cached: string | null = await redis.get('bandcampAlbums');
@ -14,47 +14,56 @@ export async function fetchBandcampAlbums() {
console.log('Cache hit!');
const ttl = await redis.ttl('bandcampAlbums');
return { ...response, cacheControl: `max-age=${ttl}` };
// Preserve array shape; attach cacheControl as a non-enumerable property.
if (typeof ttl === 'number' && ttl > 0) {
Object.defineProperty(response, 'cacheControl', {
value: `max-age=${ttl}`,
enumerable: false,
});
}
return response as Album[] & { cacheControl?: string };
}
}
const { data }: ScrapeResult<BandCampResults> = await scrapeIt(
`https://bandcamp.com/${BANDCAMP_USERNAME}`,
{
const { data }: ScrapeResult<BandCampResults> = await scrapeIt(`https://bandcamp.com/${BANDCAMP_USERNAME}`, {
collectionItems: {
listItem: '.collection-item-container',
data: {
url: {
selector: '.collection-title-details > a.item-link',
attr: 'href'
attr: 'href',
},
artwork: {
selector: 'div.collection-item-art-container a img',
attr: 'src'
attr: 'src',
},
title: {
selector: 'span.item-link-alt > div.collection-item-title'
selector: 'span.item-link-alt > div.collection-item-title',
},
artist: {
selector: 'span.item-link-alt > div.collection-item-artist'
}
}
}
}
);
selector: 'span.item-link-alt > div.collection-item-artist',
},
},
},
});
const albums: Album[] = data?.collectionItems || [];
if (albums && albums?.length > 0) {
if (USE_REDIS_CACHE === 'true') {
// Store in Redis for 12 hours.
redis.set('bandcampAlbums', JSON.stringify(albums), 'EX', 43200);
// Reflect the cache TTL on the returned array as a hint to clients.
Object.defineProperty(albums, 'cacheControl', {
value: 'max-age=43200',
enumerable: false,
});
}
return albums;
} else {
return [];
return albums as Album[] & { cacheControl?: string };
}
return [] as Album[] & { cacheControl?: string };
} catch (error) {
console.error(error);
return [];
return [] as Album[] & { cacheControl?: string };
}
}

View file

@ -1,49 +1,95 @@
<script module lang="ts">
export { blueSkyIcon, dockerIcon, drizzleIcon, honoIcon, gitHubIcon, linkedInIcon, lucideIcon, nextDotJsIcon, reactIcon, svelteIcon, typescriptIcon, xIcon };
export {
blueSkyIcon,
dockerIcon,
drizzleIcon,
honoIcon,
gitHubIcon,
linkedInIcon,
lucideIcon,
nextDotJsIcon,
reactIcon,
svelteIcon,
typescriptIcon,
xIcon,
};
</script>
{#snippet gitHubIcon()}
<path fill="currentColor" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
<path
fill="currentColor"
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/>
{/snippet}
{#snippet drizzleIcon()}
<path fill="currentColor" d="M5.353 11.823a1.036 1.036 0 0 0-.395-1.422 1.063 1.063 0 0 0-1.437.399L.138 16.702a1.035 1.035 0 0 0 .395 1.422 1.063 1.063 0 0 0 1.437-.398l3.383-5.903Zm11.216 0a1.036 1.036 0 0 0-.394-1.422 1.064 1.064 0 0 0-1.438.399l-3.382 5.902a1.036 1.036 0 0 0 .394 1.422c.506.283 1.15.104 1.438-.398l3.382-5.903Zm7.293-4.525a1.036 1.036 0 0 0-.395-1.422 1.062 1.062 0 0 0-1.437.399l-3.383 5.902a1.036 1.036 0 0 0 .395 1.422 1.063 1.063 0 0 0 1.437-.399l3.383-5.902Zm-11.219 0a1.035 1.035 0 0 0-.394-1.422 1.064 1.064 0 0 0-1.438.398l-3.382 5.903a1.036 1.036 0 0 0 .394 1.422c.506.282 1.15.104 1.438-.399l3.382-5.902Z"/>
<path
fill="currentColor"
d="M5.353 11.823a1.036 1.036 0 0 0-.395-1.422 1.063 1.063 0 0 0-1.437.399L.138 16.702a1.035 1.035 0 0 0 .395 1.422 1.063 1.063 0 0 0 1.437-.398l3.383-5.903Zm11.216 0a1.036 1.036 0 0 0-.394-1.422 1.064 1.064 0 0 0-1.438.399l-3.382 5.902a1.036 1.036 0 0 0 .394 1.422c.506.283 1.15.104 1.438-.398l3.382-5.903Zm7.293-4.525a1.036 1.036 0 0 0-.395-1.422 1.062 1.062 0 0 0-1.437.399l-3.383 5.902a1.036 1.036 0 0 0 .395 1.422 1.063 1.063 0 0 0 1.437-.399l3.383-5.902Zm-11.219 0a1.035 1.035 0 0 0-.394-1.422 1.064 1.064 0 0 0-1.438.398l-3.382 5.903a1.036 1.036 0 0 0 .394 1.422c.506.282 1.15.104 1.438-.399l3.382-5.902Z"
/>
{/snippet}
{#snippet svelteIcon()}
<path fill="currentColor" d="M10.354 21.125a4.44 4.44 0 0 1-4.765-1.767 4.109 4.109 0 0 1-.703-3.107 3.898 3.898 0 0 1 .134-.522l.105-.321.287.21a7.21 7.21 0 0 0 2.186 1.092l.208.063-.02.208a1.253 1.253 0 0 0 .226.83 1.337 1.337 0 0 0 1.435.533 1.231 1.231 0 0 0 .343-.15l5.59-3.562a1.164 1.164 0 0 0 .524-.778 1.242 1.242 0 0 0-.211-.937 1.338 1.338 0 0 0-1.435-.533 1.23 1.23 0 0 0-.343.15l-2.133 1.36a4.078 4.078 0 0 1-1.135.499 4.44 4.44 0 0 1-4.765-1.766 4.108 4.108 0 0 1-.702-3.108 3.855 3.855 0 0 1 1.742-2.582l5.589-3.563a4.072 4.072 0 0 1 1.135-.499 4.44 4.44 0 0 1 4.765 1.767 4.109 4.109 0 0 1 .703 3.107 3.943 3.943 0 0 1-.134.522l-.105.321-.286-.21a7.204 7.204 0 0 0-2.187-1.093l-.208-.063.02-.207a1.255 1.255 0 0 0-.226-.831 1.337 1.337 0 0 0-1.435-.532 1.231 1.231 0 0 0-.343.15L8.62 9.368a1.162 1.162 0 0 0-.524.778 1.24 1.24 0 0 0 .211.937 1.338 1.338 0 0 0 1.435.533 1.235 1.235 0 0 0 .344-.151l2.132-1.36a4.067 4.067 0 0 1 1.135-.498 4.44 4.44 0 0 1 4.765 1.766 4.108 4.108 0 0 1 .702 3.108 3.857 3.857 0 0 1-1.742 2.583l-5.589 3.562a4.072 4.072 0 0 1-1.135.499m10.358-17.95C18.484-.015 14.082-.96 10.9 1.068L5.31 4.63a6.412 6.412 0 0 0-2.896 4.295 6.753 6.753 0 0 0 .666 4.336 6.43 6.43 0 0 0-.96 2.396 6.833 6.833 0 0 0 1.168 5.167c2.229 3.19 6.63 4.135 9.812 2.108l5.59-3.562a6.41 6.41 0 0 0 2.896-4.295 6.756 6.756 0 0 0-.665-4.336 6.429 6.429 0 0 0 .958-2.396 6.831 6.831 0 0 0-1.167-5.168Z"/>
<path
fill="currentColor"
d="M10.354 21.125a4.44 4.44 0 0 1-4.765-1.767 4.109 4.109 0 0 1-.703-3.107 3.898 3.898 0 0 1 .134-.522l.105-.321.287.21a7.21 7.21 0 0 0 2.186 1.092l.208.063-.02.208a1.253 1.253 0 0 0 .226.83 1.337 1.337 0 0 0 1.435.533 1.231 1.231 0 0 0 .343-.15l5.59-3.562a1.164 1.164 0 0 0 .524-.778 1.242 1.242 0 0 0-.211-.937 1.338 1.338 0 0 0-1.435-.533 1.23 1.23 0 0 0-.343.15l-2.133 1.36a4.078 4.078 0 0 1-1.135.499 4.44 4.44 0 0 1-4.765-1.766 4.108 4.108 0 0 1-.702-3.108 3.855 3.855 0 0 1 1.742-2.582l5.589-3.563a4.072 4.072 0 0 1 1.135-.499 4.44 4.44 0 0 1 4.765 1.767 4.109 4.109 0 0 1 .703 3.107 3.943 3.943 0 0 1-.134.522l-.105.321-.286-.21a7.204 7.204 0 0 0-2.187-1.093l-.208-.063.02-.207a1.255 1.255 0 0 0-.226-.831 1.337 1.337 0 0 0-1.435-.532 1.231 1.231 0 0 0-.343.15L8.62 9.368a1.162 1.162 0 0 0-.524.778 1.24 1.24 0 0 0 .211.937 1.338 1.338 0 0 0 1.435.533 1.235 1.235 0 0 0 .344-.151l2.132-1.36a4.067 4.067 0 0 1 1.135-.498 4.44 4.44 0 0 1 4.765 1.766 4.108 4.108 0 0 1 .702 3.108 3.857 3.857 0 0 1-1.742 2.583l-5.589 3.562a4.072 4.072 0 0 1-1.135.499m10.358-17.95C18.484-.015 14.082-.96 10.9 1.068L5.31 4.63a6.412 6.412 0 0 0-2.896 4.295 6.753 6.753 0 0 0 .666 4.336 6.43 6.43 0 0 0-.96 2.396 6.833 6.833 0 0 0 1.168 5.167c2.229 3.19 6.63 4.135 9.812 2.108l5.59-3.562a6.41 6.41 0 0 0 2.896-4.295 6.756 6.756 0 0 0-.665-4.336 6.429 6.429 0 0 0 .958-2.396 6.831 6.831 0 0 0-1.167-5.168Z"
/>
{/snippet}
{#snippet typescriptIcon()}
<path fill="currentColor" d="M1.125 0C.502 0 0 .502 0 1.125v21.75C0 23.498.502 24 1.125 24h21.75c.623 0 1.125-.502 1.125-1.125V1.125C24 .502 23.498 0 22.875 0zm17.363 9.75c.612 0 1.154.037 1.627.111a6.38 6.38 0 0 1 1.306.34v2.458a3.95 3.95 0 0 0-.643-.361 5.093 5.093 0 0 0-.717-.26 5.453 5.453 0 0 0-1.426-.2c-.3 0-.573.028-.819.086a2.1 2.1 0 0 0-.623.242c-.17.104-.3.229-.393.374a.888.888 0 0 0-.14.49c0 .196.053.373.156.529.104.156.252.304.443.444s.423.276.696.41c.273.135.582.274.926.416.47.197.892.407 1.266.628.374.222.695.473.963.753.268.279.472.598.614.957.142.359.214.776.214 1.253 0 .657-.125 1.21-.373 1.656a3.033 3.033 0 0 1-1.012 1.085 4.38 4.38 0 0 1-1.487.596c-.566.12-1.163.18-1.79.18a9.916 9.916 0 0 1-1.84-.164 5.544 5.544 0 0 1-1.512-.493v-2.63a5.033 5.033 0 0 0 3.237 1.2c.333 0 .624-.03.872-.09.249-.06.456-.144.623-.25.166-.108.29-.234.373-.38a1.023 1.023 0 0 0-.074-1.089 2.12 2.12 0 0 0-.537-.5 5.597 5.597 0 0 0-.807-.444 27.72 27.72 0 0 0-1.007-.436c-.918-.383-1.602-.852-2.053-1.405-.45-.553-.676-1.222-.676-2.005 0-.614.123-1.141.369-1.582.246-.441.58-.804 1.004-1.089a4.494 4.494 0 0 1 1.47-.629 7.536 7.536 0 0 1 1.77-.201zm-15.113.188h9.563v2.166H9.506v9.646H6.789v-9.646H3.375z"/>
<path
fill="currentColor"
d="M1.125 0C.502 0 0 .502 0 1.125v21.75C0 23.498.502 24 1.125 24h21.75c.623 0 1.125-.502 1.125-1.125V1.125C24 .502 23.498 0 22.875 0zm17.363 9.75c.612 0 1.154.037 1.627.111a6.38 6.38 0 0 1 1.306.34v2.458a3.95 3.95 0 0 0-.643-.361 5.093 5.093 0 0 0-.717-.26 5.453 5.453 0 0 0-1.426-.2c-.3 0-.573.028-.819.086a2.1 2.1 0 0 0-.623.242c-.17.104-.3.229-.393.374a.888.888 0 0 0-.14.49c0 .196.053.373.156.529.104.156.252.304.443.444s.423.276.696.41c.273.135.582.274.926.416.47.197.892.407 1.266.628.374.222.695.473.963.753.268.279.472.598.614.957.142.359.214.776.214 1.253 0 .657-.125 1.21-.373 1.656a3.033 3.033 0 0 1-1.012 1.085 4.38 4.38 0 0 1-1.487.596c-.566.12-1.163.18-1.79.18a9.916 9.916 0 0 1-1.84-.164 5.544 5.544 0 0 1-1.512-.493v-2.63a5.033 5.033 0 0 0 3.237 1.2c.333 0 .624-.03.872-.09.249-.06.456-.144.623-.25.166-.108.29-.234.373-.38a1.023 1.023 0 0 0-.074-1.089 2.12 2.12 0 0 0-.537-.5 5.597 5.597 0 0 0-.807-.444 27.72 27.72 0 0 0-1.007-.436c-.918-.383-1.602-.852-2.053-1.405-.45-.553-.676-1.222-.676-2.005 0-.614.123-1.141.369-1.582.246-.441.58-.804 1.004-1.089a4.494 4.494 0 0 1 1.47-.629 7.536 7.536 0 0 1 1.77-.201zm-15.113.188h9.563v2.166H9.506v9.646H6.789v-9.646H3.375z"
/>
{/snippet}
{#snippet reactIcon()}
<path fill="currentColor" d="M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.41 0-.783.093-1.106.278-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03-.704 3.113-.39 5.588.988 6.38.32.187.69.275 1.102.275 1.345 0 3.107-.96 4.888-2.624 1.78 1.654 3.542 2.603 4.887 2.603.41 0 .783-.09 1.106-.275 1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032.704-3.11.39-5.587-.988-6.38-.318-.184-.688-.277-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127.666.382.955 1.835.73 3.704-.054.46-.142.945-.25 1.44-.96-.236-2.006-.417-3.107-.534-.66-.905-1.345-1.727-2.035-2.447 1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28-.686.72-1.37 1.537-2.02 2.442-1.107.117-2.154.298-3.113.538-.112-.49-.195-.964-.254-1.42-.23-1.868.054-3.32.714-3.707.19-.09.4-.127.563-.132zm4.882 3.05c.455.468.91.992 1.36 1.564-.44-.02-.89-.034-1.345-.034-.46 0-.915.01-1.36.034.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093.406.582.802 1.203 1.183 1.86.372.64.71 1.29 1.018 1.946-.308.655-.646 1.31-1.013 1.95-.38.66-.773 1.288-1.18 1.87-.728.063-1.466.098-2.21.098-.74 0-1.477-.035-2.202-.093-.406-.582-.802-1.204-1.183-1.86-.372-.64-.71-1.29-1.018-1.946.303-.657.646-1.313 1.013-1.954.38-.66.773-1.286 1.18-1.868.728-.064 1.466-.098 2.21-.098zm-3.635.254c-.24.377-.48.763-.704 1.16-.225.39-.435.782-.635 1.174-.265-.656-.49-1.31-.676-1.947.64-.15 1.315-.283 2.015-.386zm7.26 0c.695.103 1.365.23 2.006.387-.18.632-.405 1.282-.66 1.933-.2-.39-.41-.783-.64-1.174-.225-.392-.465-.774-.705-1.146zm3.063.675c.484.15.944.317 1.375.498 1.732.74 2.852 1.708 2.852 2.476-.005.768-1.125 1.74-2.857 2.475-.42.18-.88.342-1.355.493-.28-.958-.646-1.956-1.1-2.98.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98-.45 1.017-.812 2.01-1.086 2.964-.484-.15-.944-.318-1.37-.5-1.732-.737-2.852-1.706-2.852-2.474 0-.768 1.12-1.742 2.852-2.476.42-.18.88-.342 1.356-.494zm11.678 4.28c.265.657.49 1.312.676 1.948-.64.157-1.316.29-2.016.39.24-.375.48-.762.705-1.158.225-.39.435-.788.636-1.18zm-9.945.02c.2.392.41.783.64 1.175.23.39.465.772.705 1.143-.695-.102-1.365-.23-2.006-.386.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423.23 1.868-.054 3.32-.714 3.708-.147.09-.338.128-.563.128-1.012 0-2.514-.807-4.11-2.28.686-.72 1.37-1.536 2.02-2.44 1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532.66.905 1.345 1.727 2.035 2.446-1.595 1.483-3.092 2.295-4.11 2.295-.22-.005-.406-.05-.553-.132-.666-.38-.955-1.834-.73-3.703.054-.46.142-.944.25-1.438zm4.56.64c.44.02.89.034 1.345.034.46 0 .915-.01 1.36-.034-.44.572-.895 1.095-1.345 1.565-.455-.47-.91-.993-1.36-1.565z"/>
<path
fill="currentColor"
d="M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.41 0-.783.093-1.106.278-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03-.704 3.113-.39 5.588.988 6.38.32.187.69.275 1.102.275 1.345 0 3.107-.96 4.888-2.624 1.78 1.654 3.542 2.603 4.887 2.603.41 0 .783-.09 1.106-.275 1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032.704-3.11.39-5.587-.988-6.38-.318-.184-.688-.277-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127.666.382.955 1.835.73 3.704-.054.46-.142.945-.25 1.44-.96-.236-2.006-.417-3.107-.534-.66-.905-1.345-1.727-2.035-2.447 1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28-.686.72-1.37 1.537-2.02 2.442-1.107.117-2.154.298-3.113.538-.112-.49-.195-.964-.254-1.42-.23-1.868.054-3.32.714-3.707.19-.09.4-.127.563-.132zm4.882 3.05c.455.468.91.992 1.36 1.564-.44-.02-.89-.034-1.345-.034-.46 0-.915.01-1.36.034.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093.406.582.802 1.203 1.183 1.86.372.64.71 1.29 1.018 1.946-.308.655-.646 1.31-1.013 1.95-.38.66-.773 1.288-1.18 1.87-.728.063-1.466.098-2.21.098-.74 0-1.477-.035-2.202-.093-.406-.582-.802-1.204-1.183-1.86-.372-.64-.71-1.29-1.018-1.946.303-.657.646-1.313 1.013-1.954.38-.66.773-1.286 1.18-1.868.728-.064 1.466-.098 2.21-.098zm-3.635.254c-.24.377-.48.763-.704 1.16-.225.39-.435.782-.635 1.174-.265-.656-.49-1.31-.676-1.947.64-.15 1.315-.283 2.015-.386zm7.26 0c.695.103 1.365.23 2.006.387-.18.632-.405 1.282-.66 1.933-.2-.39-.41-.783-.64-1.174-.225-.392-.465-.774-.705-1.146zm3.063.675c.484.15.944.317 1.375.498 1.732.74 2.852 1.708 2.852 2.476-.005.768-1.125 1.74-2.857 2.475-.42.18-.88.342-1.355.493-.28-.958-.646-1.956-1.1-2.98.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98-.45 1.017-.812 2.01-1.086 2.964-.484-.15-.944-.318-1.37-.5-1.732-.737-2.852-1.706-2.852-2.474 0-.768 1.12-1.742 2.852-2.476.42-.18.88-.342 1.356-.494zm11.678 4.28c.265.657.49 1.312.676 1.948-.64.157-1.316.29-2.016.39.24-.375.48-.762.705-1.158.225-.39.435-.788.636-1.18zm-9.945.02c.2.392.41.783.64 1.175.23.39.465.772.705 1.143-.695-.102-1.365-.23-2.006-.386.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423.23 1.868-.054 3.32-.714 3.708-.147.09-.338.128-.563.128-1.012 0-2.514-.807-4.11-2.28.686-.72 1.37-1.536 2.02-2.44 1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532.66.905 1.345 1.727 2.035 2.446-1.595 1.483-3.092 2.295-4.11 2.295-.22-.005-.406-.05-.553-.132-.666-.38-.955-1.834-.73-3.703.054-.46.142-.944.25-1.438zm4.56.64c.44.02.89.034 1.345.034.46 0 .915-.01 1.36-.034-.44.572-.895 1.095-1.345 1.565-.455-.47-.91-.993-1.36-1.565z"
/>
{/snippet}
{#snippet dockerIcon()}
<path fill="currentColor" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>
<path
fill="currentColor"
d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
/>
{/snippet}
{#snippet honoIcon()}
<path fill="currentColor" d="M12.445.002a45.529 45.529 0 0 0-5.252 8.146 8.595 8.595 0 0 1-.555-.53 27.796 27.796 0 0 0-1.205-1.542 8.762 8.762 0 0 0-1.251 2.12 20.743 20.743 0 0 0-1.448 5.88 8.867 8.867 0 0 0 .338 3.468c1.312 3.48 3.794 5.593 7.445 6.337 3.055.438 5.755-.333 8.097-2.312 2.677-2.59 3.359-5.634 2.047-9.132a33.287 33.287 0 0 0-2.988-5.59A91.34 91.34 0 0 0 12.615.053a.216.216 0 0 0-.17-.051Zm-.336 3.906a50.93 50.93 0 0 1 4.794 6.552c.448.767.817 1.57 1.108 2.41.606 2.386-.044 4.354-1.951 5.904-1.845 1.298-3.87 1.683-6.072 1.156-2.376-.737-3.75-2.335-4.121-4.794a5.107 5.107 0 0 1 .242-2.266c.358-.908.79-1.774 1.3-2.601l1.446-2.121a397.33 397.33 0 0 0 3.254-4.24Z"/>
<path
fill="currentColor"
d="M12.445.002a45.529 45.529 0 0 0-5.252 8.146 8.595 8.595 0 0 1-.555-.53 27.796 27.796 0 0 0-1.205-1.542 8.762 8.762 0 0 0-1.251 2.12 20.743 20.743 0 0 0-1.448 5.88 8.867 8.867 0 0 0 .338 3.468c1.312 3.48 3.794 5.593 7.445 6.337 3.055.438 5.755-.333 8.097-2.312 2.677-2.59 3.359-5.634 2.047-9.132a33.287 33.287 0 0 0-2.988-5.59A91.34 91.34 0 0 0 12.615.053a.216.216 0 0 0-.17-.051Zm-.336 3.906a50.93 50.93 0 0 1 4.794 6.552c.448.767.817 1.57 1.108 2.41.606 2.386-.044 4.354-1.951 5.904-1.845 1.298-3.87 1.683-6.072 1.156-2.376-.737-3.75-2.335-4.121-4.794a5.107 5.107 0 0 1 .242-2.266c.358-.908.79-1.774 1.3-2.601l1.446-2.121a397.33 397.33 0 0 0 3.254-4.24Z"
/>
{/snippet}
{#snippet linkedInIcon()}
<path fill="currentColor" d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
<path
fill="currentColor"
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
/>
{/snippet}
{#snippet xIcon()}
<path fill="currentColor" d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"/>
<path
fill="currentColor"
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
/>
{/snippet}
{#snippet blueSkyIcon()}
<path fill="currentColor" d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"/>
<path
fill="currentColor"
d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"
/>
{/snippet}
{#snippet nextDotJsIcon()}
<path fill="currentColor" d="M18.665 21.978C16.758 23.255 14.465 24 12 24 5.377 24 0 18.623 0 12S5.377 0 12 0s12 5.377 12 12c0 3.583-1.574 6.801-4.067 9.001L9.219 7.2H7.2v9.596h1.615V9.251l9.85 12.727Zm-3.332-8.533 1.6 2.061V7.2h-1.6v6.245Z"/>
<path
fill="currentColor"
d="M18.665 21.978C16.758 23.255 14.465 24 12 24 5.377 24 0 18.623 0 12S5.377 0 12 0s12 5.377 12 12c0 3.583-1.574 6.801-4.067 9.001L9.219 7.2H7.2v9.596h1.615V9.251l9.85 12.727Zm-3.332-8.533 1.6 2.061V7.2h-1.6v6.245Z"
/>
{/snippet}
{#snippet lucideIcon(icon: any)}

View file

@ -1,11 +1,9 @@
<script lang="ts">
import ExternalLink from '$lib/components/ExternalLink.svelte';
import { lucideIcon } from '$lib/util/logoIcons.svelte';
import type { Snippet } from "svelte";
import type { LinkTextType } from '$lib/types/externalLinkTypes';
interface Props {
linkData: LinkTextType;
ariaLabel: string;
href: string;
clazz?: string;
@ -14,41 +12,44 @@
}
let { ariaLabel, href, clazz = '', textData, icon }: Props = $props();
// Ensure a stable class for styling
const mergedClazz = `${clazz} tech-list-item`.trim();
</script>
<ExternalLink
linkData={{ href, ariaLabel, clazz }}
linkData={{ href, ariaLabel, clazz: mergedClazz }}
textData={textData}
iconData={{ type: 'icon', icon }}
iconData={{ type: 'svg', icon }}
/>
<style lang="postcss">
a {
/* Style the link rendered inside ExternalLink via a specific class */
:global(a.tech-list-item) {
display: grid;
justify-items: center;
font-weight: bold;
margin-right: 0;
text-decoration: none;
padding: 0.3rem;
margin-left: 1rem;
color: var(--lightGrey);
}
& p {
:global(a.tech-list-item p) {
font-size: 1.5rem;
padding-top: 0.3rem;
margin: 0;
}
&:hover {
:global(a.tech-list-item:hover) {
color: var(--shellYellow);
& p {
color: var(--shellYellow);
}
}
}
svg {
:global(a.tech-list-item:hover p) {
color: var(--shellYellow);
}
:global(a.tech-list-item svg) {
color: white;
}
</style>

View file

@ -1,7 +1,7 @@
import { json, error } from '@sveltejs/kit';
import { PAGE_SIZE } from '$env/static/private';
import { fetchArticlesApi } from '$lib/api';
import { json } from '@sveltejs/kit';
import type { ArticlePageLoad } from '@/lib/types/article.js';
import { PAGE_SIZE } from '$env/static/private';
import { fetchArticlesApi } from '$lib/services/articlesApi';
export async function GET({ setHeaders, url }) {
const page = url?.searchParams?.get('page') || '1';
@ -13,18 +13,18 @@ export async function GET({ setHeaders, url }) {
try {
const response: ArticlePageLoad = await fetchArticlesApi('get', 'fetchArticles', {
page,
limit
limit,
});
if (response?.articles) {
if (response?.cacheControl) {
if (!response.cacheControl.includes('no-cache')) {
setHeaders({
'cache-control': response?.cacheControl
'cache-control': response?.cacheControl,
});
} else {
setHeaders({
'cache-control': 'max-age=43200'
'cache-control': 'max-age=43200',
});
}
}
@ -33,6 +33,19 @@ export async function GET({ setHeaders, url }) {
}
} catch (e) {
console.error(e);
error(404, 'Page does not exist');
// Fall back to an empty, cacheable payload so pages can still render in E2E
const fallback: ArticlePageLoad = {
articles: [],
currentPage: Number(page) || 1,
totalArticles: 0,
totalPages: 1,
limit: Number(limit) || 10,
cacheControl: 'no-cache',
} as unknown as ArticlePageLoad;
return json(fallback, {
headers: {
'cache-control': 'no-cache',
},
});
}
};
}

View file

@ -1,61 +1,66 @@
import { json, error } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import scrapeIt, { type ScrapeResult } from 'scrape-it';
import { BANDCAMP_USERNAME, USE_REDIS_CACHE } from '$env/static/private';
import { redis } from '$lib/server/redis';
import type { Album, BandCampResults } from '$lib/types/album';
import scrapeIt, { type ScrapeResult } from 'scrape-it';
export async function GET({ setHeaders, url }) {
async function retryWithBackoff<T>(fn: () => Promise<T>, maxRetries = 3, baseDelay = 500): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err as Error;
if (attempt === maxRetries) break;
const delay = baseDelay * 2 ** attempt; // 500ms, 1s, 2s
await new Promise((r) => setTimeout(r, delay));
}
}
throw lastError;
}
export async function GET({ setHeaders }) {
try {
if (USE_REDIS_CACHE === 'true') {
const cached: string | null = await redis.get('bandcampAlbums');
if (cached) {
const response: Album[] = JSON.parse(cached);
const ttl = await redis.ttl("bandcampAlbums");
const ttl = await redis.ttl('bandcampAlbums');
if (ttl) {
setHeaders({
"cache-control": `max-age=${ttl}`,
'cache-control': `max-age=${ttl}`,
});
} else {
setHeaders({
"cache-control": "max-age=43200",
'cache-control': 'max-age=43200',
});
}
return json(response);
}
}
const { data }: ScrapeResult<BandCampResults> = await scrapeIt(`https://bandcamp.com/${BANDCAMP_USERNAME}`, {
// Scrape Bandcamp with realistic headers, plus retry/backoff
const { data }: ScrapeResult<BandCampResults> = await retryWithBackoff(async () =>
await scrapeIt(`https://bandcamp.com/${BANDCAMP_USERNAME}`, {
collectionItems: {
listItem: '.collection-item-container',
data: {
url: {
selector: '.collection-title-details > a.item-link',
attr: 'href',
},
artwork: {
selector: 'div.collection-item-art-container a img',
attr: 'src',
},
title: {
selector: 'span.item-link-alt > div.collection-item-title',
},
artist: {
selector: 'span.item-link-alt > div.collection-item-artist',
url: { selector: '.collection-title-details > a.item-link', attr: 'href' },
artwork: { selector: 'div.collection-item-art-container a img', attr: 'src' },
title: { selector: 'span.item-link-alt > div.collection-item-title' },
artist: { selector: 'span.item-link-alt > div.collection-item-artist' },
},
},
},
});
})
);
const albums: Album[] = data?.collectionItems || [];
if (albums && albums?.length > 0) {
if (albums && albums.length > 0) {
if (USE_REDIS_CACHE === 'true') {
redis.set('bandcampAlbums', JSON.stringify(albums), 'EX', 43200);
}
setHeaders({
"cache-control": "max-age=43200",
});
setHeaders({ 'cache-control': 'max-age=43200' });
return json(albums);
}
return json([]);

View file

@ -5,8 +5,6 @@ export const load: LayoutServerLoad = async ({ fetch }) => {
const resp = await fetch('/api/articles?page=1');
const data = await resp.json();
console.log('Data: ', JSON.stringify(data));
return {
// Common metadata available to all child routes
totalArticles: data.totalArticles,

View file

@ -1,23 +1,21 @@
<script lang="ts">
import ExternalLink from "$lib/components/ExternalLink.svelte";
import Portfolio from "./Portfolio.svelte";
// import OldWebsite from "$lib/content/portfolio/personal/old-website.md";
// import PersonalWebsiteSvelteKit from "$lib/content/portfolio/personal/personal-website-sveltekit.md";
// import WeddingWebsite from "$lib/content/portfolio/personal/wedding-website.md";
// import MarkShellnutArchitect from "$lib/content/portfolio/professional/mark-shellnut-architect.md";
import type { ExternalLinkType } from "$lib/types/externalLinkType";
import { Tabs } from "bits-ui";
import personalSite from "../../lib/assets/images/portfolio/Bradley_Shellnut_New_Site.png?enhanced";
import shellnutArchitectWebsite from "../../lib/assets/images/portfolio/Mark_Shellnut_Architect.png?enhanced";
import oldSite from "../../lib/assets/images/portfolio/Old_Website_Bradley_Shellnut.png?enhanced";
import weddingWebsite from "../../lib/assets/images/portfolio/Wedding_Website.png?enhanced";
import { gitHubIcon } from "$lib/util/logoIcons.svelte";
import { Tabs } from 'bits-ui';
import ExternalLink from '$lib/components/ExternalLink.svelte';
import type { ExternalLinkType } from '$lib/types/externalLinkType';
import { gitHubIcon } from '$lib/util/logoIcons.svelte';
import personalSite from '../../lib/assets/images/portfolio/Bradley_Shellnut_New_Site.png?enhanced';
import shellnutArchitectWebsite from '../../lib/assets/images/portfolio/Mark_Shellnut_Architect.png?enhanced';
import oldSite from '../../lib/assets/images/portfolio/Old_Website_Bradley_Shellnut.png?enhanced';
import weddingWebsite from '../../lib/assets/images/portfolio/Wedding_Website.png?enhanced';
import Portfolio from './Portfolio.svelte';
</script>
{#snippet links(externalLinks: ExternalLinkType[])}
<span>
{#each externalLinks as link}
{#if link.icon && link.showIcon}
{#if typeof link.icon === 'function' && 'length' in link.icon}
<!-- Snippet icon: pass snippet directly for LinkIconType 'svg' -->
<ExternalLink
linkData={{
href: link.href,
@ -30,8 +28,25 @@
showIcon: link.showIcon,
location: "left",
}}
iconData={{ type: "svg", icon: link.icon }}
iconData={{ type: 'svg', icon: link.icon as any }}
/>
{:else}
<!-- Component icon (e.g., lucide-svelte) -->
<ExternalLink
linkData={{
href: link.href,
ariaLabel: link.ariaLabel,
title: link.ariaLabel,
target: "_blank",
}}
textData={{
text: link.text,
showIcon: link.showIcon,
location: "left",
}}
iconData={{ type: 'icon', icon: link.icon as any }}
/>
{/if}
{:else}
<ExternalLink
linkData={{
@ -264,15 +279,15 @@
display: flex;
flex-direction: column;
&[data-orientation="vertical"] {
flex-direction: row;
}
@media (min-width: 1000px) {
max-width: 50vw;
}
}
:global([data-tabs-root][data-orientation="vertical"]) {
flex-direction: row;
}
:global([data-tabs-list]) {
display: grid;
gap: 1rem;

View file

@ -47,7 +47,7 @@
gap: 2rem;
}
.uses-image img {
:global(.uses-image img) {
height: auto;
margin-left: auto;
margin-right: auto;

View file

@ -20,7 +20,6 @@ const config = {
}
},
compilerOptions: {
enableSourcemap: process.env.NODE_ENV === 'development',
css: 'injected'
}
};

238
tests/about.test.ts Normal file
View file

@ -0,0 +1,238 @@
import { expect, test } from '@playwright/test';
test.describe('About page', () => {
test('has expected main heading', async ({ page }) => {
await page.goto('/about');
await expect(page.getByRole('heading', { level: 1, name: 'About' })).toBeVisible();
});
test('header/footer links hover: color becomes shellYellow', async ({ page }) => {
await page.goto('/about');
const shellYellow = await page.evaluate(() => {
const probe = document.createElement('div');
probe.style.color = 'var(--shellYellow)';
document.body.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
});
const areas = [
'header[aria-label="header navigation"]',
'footer nav[aria-label="footer navigation"]',
];
for (const area of areas) {
const nav = page.locator(area);
await expect(nav).toBeVisible();
const link = nav.getByRole('link', { name: 'Portfolio', exact: true });
await expect(link).toBeVisible();
const before = await link.evaluate((el) => {
const cs = getComputedStyle(el as Element) as CSSStyleDeclaration;
return { color: cs.color };
});
await link.hover();
const after = await link.evaluate((el) => {
const cs = getComputedStyle(el as Element) as CSSStyleDeclaration;
return { color: cs.color };
});
expect(after.color).toBe(shellYellow);
// Sanity: it should change from the default color
expect(after.color).not.toBe(before.color);
}
});
test('current page (About) link is active in header and footer', async ({ page }) => {
await page.goto('/about');
const areas = [
'header[aria-label="header navigation"]',
'footer nav[aria-label="footer navigation"]',
];
for (const area of areas) {
const nav = page.locator(area);
const aboutLink = nav.getByRole('link', { name: 'About', exact: true });
await expect(aboutLink).toBeVisible();
const isActive = await aboutLink.evaluate((el) => (el as Element).classList.contains('active'));
expect(isActive).toBeTruthy();
}
});
test('tech list hover changes color to shellYellow', async ({ page }) => {
await page.goto('/about');
const techList = page.locator('.tech-list');
await expect(techList).toBeVisible();
// Resolve the actual computed rgb color value for --shellYellow in the browser context
const shellYellow = await page.evaluate(() => {
const probe = document.createElement('div');
probe.style.color = 'var(--shellYellow)';
document.body.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
});
const names = ['Svelte', 'Hono', 'TypeScript', 'Drizzle ORM', 'React', 'Next.js', 'Docker'];
for (const name of names) {
const link = techList.locator(`a[title="${name}"]`).first();
await expect(link).toBeVisible();
const before = await link.evaluate((el) => getComputedStyle(el as Element).color);
await link.hover();
const after = await link.evaluate((el) => getComputedStyle(el as Element).color);
expect(before).not.toBe(shellYellow);
expect(after).toBe(shellYellow);
}
});
test('tech list has accessible links for key technologies', async ({ page }) => {
await page.goto('/about');
const techList = page.locator('.tech-list');
await expect(techList).toBeVisible();
const names = ['Svelte', 'Hono', 'TypeScript', 'Drizzle ORM', 'React', 'Next.js', 'Docker'];
for (const name of names) {
const link = techList.locator(`a[title="${name}"]`).first();
await expect(link).toBeVisible();
await expect(link).toHaveAccessibleName(new RegExp(name, 'i'));
}
});
test('tablet viewport (~800px): extracurricular wraps to multiple rows', async ({ page }) => {
await page.setViewportSize({ width: 800, height: 1000 });
await page.goto('/about');
const container = page.locator('.extracurricular');
await expect(container).toBeVisible();
const cards = container.locator('.card');
const count = await cards.count();
expect(count).toBeGreaterThanOrEqual(3);
const [c0, c1, c2] = await Promise.all([
cards.nth(0).boundingBox(),
cards.nth(1).boundingBox(),
cards.nth(2).boundingBox(),
]);
expect(c0 && c1 && c2).toBeTruthy();
if (c0 && c1 && c2) {
// first two side-by-side on same row, third wrapped below
expect(Math.abs(c0.y - c1.y)).toBeLessThan(10);
expect(c2.y).toBeGreaterThan(c0.y + 10);
}
});
test('mobile viewport (375px): extracurricular cards stack vertically', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 900 });
await page.goto('/about');
const container = page.locator('.extracurricular');
const cards = container.locator('.card');
const count = await cards.count();
expect(count).toBeGreaterThanOrEqual(2);
const [a, b] = await Promise.all([
cards.nth(0).boundingBox(),
cards.nth(1).boundingBox(),
]);
expect(a && b).toBeTruthy();
if (a && b) {
expect(b.y).toBeGreaterThan(a.y + 10);
expect(Math.abs(b.x - a.x)).toBeLessThan(40);
}
});
// Mirror header link presence from home tests
test('header navigation shows expected links', async ({ page }) => {
await page.goto('/about');
const headerNav = page.locator('header[aria-label="header navigation"]');
await expect(headerNav).toBeVisible();
await expect(headerNav.getByRole('link', { name: 'Home', exact: true })).toBeVisible();
await expect(headerNav.getByRole('link', { name: 'About', exact: true })).toBeVisible();
await expect(headerNav.getByRole('link', { name: 'Portfolio', exact: true })).toBeVisible();
await expect(headerNav.getByRole('link', { name: 'Uses', exact: true })).toBeVisible();
});
// Mirror header navigation flow from home tests (starting on /about)
test('header navigation links go to correct routes (from /about)', async ({ page }) => {
await page.goto('/about');
const headerNav = page.locator('header[aria-label="header navigation"]');
await headerNav.getByRole('link', { name: 'Portfolio', exact: true }).click();
await expect(page).toHaveURL(/\/portfolio\/?$/);
await headerNav.getByRole('link', { name: 'Uses', exact: true }).click();
await expect(page).toHaveURL(/\/uses\/?$/);
await headerNav.getByRole('link', { name: 'Home', exact: true }).click();
await expect(page).toHaveURL(/\/?$/);
await headerNav.getByRole('link', { name: 'About', exact: true }).click();
await expect(page).toHaveURL(/\/about\/?$/);
});
// Mirror footer link presence from home tests
test('footer shows expected links', async ({ page }) => {
await page.goto('/about');
const footerNav = page.getByRole('navigation', { name: 'footer navigation' });
await expect(footerNav).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Home', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'About', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Portfolio', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Uses', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Privacy', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Favorite Articles', exact: true })).toBeVisible();
});
// Mirror footer navigation flow from home tests (starting on /about)
test('footer navigation links go to correct routes (from /about)', async ({ page }) => {
await page.goto('/about');
const footerNav = page.getByRole('navigation', { name: 'footer navigation' });
await footerNav.getByRole('link', { name: 'Privacy', exact: true }).scrollIntoViewIfNeeded();
await footerNav.getByRole('link', { name: 'Privacy', exact: true }).click();
await expect(page).toHaveURL(/\/privacy\/?$/);
// Favorite Articles may route to /articles or /articles/1
const fav = footerNav.getByRole('link', { name: 'Favorite Articles', exact: true });
await fav.scrollIntoViewIfNeeded();
const href = await fav.getAttribute('href');
expect(href).toMatch(/\/articles(\/\d+)?\/?$/);
await page.goto(href!);
await expect(page).toHaveURL(/\/articles(\/\d+)?\/?$/, { timeout: 15000 });
await footerNav.getByRole('link', { name: 'About', exact: true }).scrollIntoViewIfNeeded();
await footerNav.getByRole('link', { name: 'About', exact: true }).click();
await expect(page).toHaveURL(/\/about\/?$/);
await footerNav.getByRole('link', { name: 'Home', exact: true }).scrollIntoViewIfNeeded();
await footerNav.getByRole('link', { name: 'Home', exact: true }).click();
await expect(page).toHaveURL(/\/?$/);
});
// Mobile viewport: ensure cat section has no horizontal overflow and second image fits viewport
test('mobile: cat section no horizontal overflow; second cat image fully visible', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 800 });
await page.goto('/about');
const catSection = page.locator('.cat-pics');
await catSection.scrollIntoViewIfNeeded();
// The cat section itself should not horizontally overflow its own box
const sectionOverflowX = await catSection.evaluate((el) => el.scrollWidth - el.clientWidth);
expect(sectionOverflowX).toBeLessThanOrEqual(2);
// Second image inside .cat-pics is fully within the cat section horizontally
const img = page.locator('.cat-pics figure:nth-of-type(2) img');
await expect(img).toBeVisible();
const [imgBox, sectionBox] = await Promise.all([
img.boundingBox(),
catSection.boundingBox(),
]);
expect(imgBox && sectionBox).toBeTruthy();
if (imgBox && sectionBox) {
expect(imgBox.x).toBeGreaterThanOrEqual(sectionBox.x - 1);
expect(imgBox.x + imgBox.width).toBeLessThanOrEqual(sectionBox.x + sectionBox.width + 1);
}
});
});

View file

@ -1,6 +1,228 @@
import { expect, test } from '@playwright/test';
test('index page has expected h1', async ({ page }) => {
test.describe('Home page', () => {
test('has expected main heading', async ({ page }) => {
await page.goto('/');
expect(await page.textContent('h1')).toBe("Hello! I'm Bradley Shellnut.");
await expect(page.locator('h1')).toHaveText("Hello! I'm Bradley Shellnut.");
});
test('header/footer links hover: color becomes shellYellow', async ({ page }) => {
await page.goto('/');
const shellYellow = await page.evaluate(() => {
const probe = document.createElement('div');
probe.style.color = 'var(--shellYellow)';
document.body.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
});
const areas = [
'header[aria-label="header navigation"]',
'footer nav[aria-label="footer navigation"]',
];
for (const area of areas) {
const nav = page.locator(area);
await expect(nav).toBeVisible();
const link = nav.getByRole('link', { name: 'Portfolio', exact: true });
await expect(link).toBeVisible();
const before = await link.evaluate((el) => {
const cs = getComputedStyle(el);
return { color: cs.color };
});
await link.hover();
const after = await link.evaluate((el) => {
const cs = getComputedStyle(el);
return { color: cs.color };
});
expect(after.color).toBe(shellYellow);
expect(after.color).not.toBe(before.color);
}
});
test('current page (Home) link is active in header and footer', async ({ page }) => {
await page.goto('/');
const areas = [
'header[aria-label="header navigation"]',
'footer nav[aria-label="footer navigation"]',
];
for (const area of areas) {
const nav = page.locator(area);
const link = nav.getByRole('link', { name: 'Home', exact: true });
await expect(link).toBeVisible();
const isActive = await link.evaluate((el) => el.classList.contains('active'));
expect(isActive).toBeTruthy();
}
});
test('header navigation links go to correct routes', async ({ page }) => {
await page.goto('/');
const headerNav = page.locator('header[aria-label="header navigation"]');
// About
await headerNav.getByRole('link', { name: 'About', exact: true }).click();
await expect(page).toHaveURL(/\/about\/?$/);
// Portfolio
await headerNav.getByRole('link', { name: 'Portfolio', exact: true }).click();
await expect(page).toHaveURL(/\/portfolio\/?$/);
// Uses
await headerNav.getByRole('link', { name: 'Uses', exact: true }).click();
await expect(page).toHaveURL(/\/uses\/?$/);
// Home
await headerNav.getByRole('link', { name: 'Home', exact: true }).click();
await expect(page).toHaveURL(/\/?$/);
});
test('header navigation shows expected links', async ({ page }) => {
await page.goto('/');
const headerNavContainer = page.locator('header[aria-label="header navigation"]');
await expect(headerNavContainer).toBeVisible();
await expect(headerNavContainer.getByRole('link', { name: 'Home', exact: true })).toBeVisible();
await expect(headerNavContainer.getByRole('link', { name: 'About', exact: true })).toBeVisible();
await expect(headerNavContainer.getByRole('link', { name: 'Portfolio', exact: true })).toBeVisible();
await expect(headerNavContainer.getByRole('link', { name: 'Uses', exact: true })).toBeVisible();
});
test('shows key sections', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 2, name: 'Currently listening to:' })).toBeVisible();
await expect(page.getByRole('heading', { level: 2, name: 'Favorite Articles' })).toBeVisible();
});
test('renders Bandcamp albums (max 6)', async ({ page }) => {
await page.goto('/');
const albumImages = page.locator('.albumsStyles .album-artwork');
const count = await albumImages.count();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThanOrEqual(6);
});
test('renders at least one favorite article card', async ({ page }) => {
await page.goto('/');
const cards = page.locator('section.articles article.card');
await expect(cards.first()).toBeVisible();
});
test('"more articles" link points to /articles and navigates', async ({ page }) => {
await page.goto('/');
const more = page.locator('a.moreArticles');
await expect(more).toHaveAttribute('href', '/articles/1');
await expect(more).toContainText('more articles');
await more.scrollIntoViewIfNeeded();
const href = await more.getAttribute('href');
expect(href).toMatch(/\/articles(\/\d+)?\/?$/);
await page.goto(href!);
await expect(page).toHaveURL(/\/articles(\/\d+)?\/?$/, { timeout: 15000 });
});
test('has social/contact links', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('link', { name: 'Contact through LinkedIn', exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: 'Contact through Github', exact: true })).toBeVisible();
});
test('footer shows expected links', async ({ page }) => {
await page.goto('/');
const footerNav = page.getByRole('navigation', { name: 'footer navigation' });
await expect(footerNav).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Home', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'About', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Portfolio', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Uses', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Privacy', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Favorite Articles', exact: true })).toBeVisible();
});
test('small viewport: Bandcamp grid 2x3 above Articles', async ({ page }) => {
await page.setViewportSize({ width: 800, height: 1000 }); // <1000px and >575px
await page.goto('/');
const albumsGrid = page.locator('.albumsStyles');
const articlesSection = page.locator('section.articles');
await expect(albumsGrid).toBeVisible();
await expect(articlesSection).toBeVisible();
// Order: Bandcamp above Articles
const [albumsTop, articlesTop] = await Promise.all([
albumsGrid.boundingBox().then((b) => b?.y ?? Number.POSITIVE_INFINITY),
articlesSection.boundingBox().then((b) => b?.y ?? Number.NEGATIVE_INFINITY),
]);
expect(albumsTop).toBeLessThan(articlesTop);
// Layout: assert first two items share the same row, third wraps to next row
const albumItems = page.locator('.albumsStyles .album-artwork');
const n = await albumItems.count();
expect(n).toBeGreaterThanOrEqual(3);
const [b0, b1, b2] = await Promise.all([
albumItems.nth(0).boundingBox(),
albumItems.nth(1).boundingBox(),
albumItems.nth(2).boundingBox(),
]);
expect(b0 && b1 && b2).toBeTruthy();
if (b0 && b1 && b2) {
expect(Math.abs(b0.y - b1.y)).toBeLessThan(6); // same row
expect(b2.y).toBeGreaterThan(b0.y + 10); // next row
}
});
test('mobile viewport: Bandcamp vertical scroll, Articles stacked', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 800 }); // <=575px rules apply
await page.goto('/');
const albumsGrid = page.locator('.albumsStyles');
const articlesSection = page.locator('section.articles');
await expect(albumsGrid).toBeVisible();
await expect(articlesSection).toBeVisible();
// Order: Bandcamp above Articles
const [albumsTop, articlesTop] = await Promise.all([
albumsGrid.boundingBox().then((b) => b?.y ?? Number.POSITIVE_INFINITY),
articlesSection.boundingBox().then((b) => b?.y ?? Number.NEGATIVE_INFINITY),
]);
expect(albumsTop).toBeLessThan(articlesTop);
// Layout: single column and scrollable vertically
const scrollInfo = await albumsGrid.evaluate((el) => ({
overflowY: getComputedStyle(el).overflowY,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
}));
expect(scrollInfo.clientHeight).toBeLessThan(scrollInfo.scrollHeight);
expect(['auto', 'scroll']).toContain(scrollInfo.overflowY);
// Albums are a vertical list (y increasing); first two must be on different rows
const albumItems = page.locator('.albumsStyles .album-artwork');
const m = await albumItems.count();
expect(m).toBeGreaterThanOrEqual(2);
const [a0, a1] = await Promise.all([
albumItems.nth(0).boundingBox(),
albumItems.nth(1).boundingBox(),
]);
expect(a0 && a1).toBeTruthy();
if (a0 && a1) {
expect(a1.y).toBeGreaterThan(a0.y + 10);
expect(Math.abs(a1.x - a0.x)).toBeLessThan(6);
}
// Articles are a vertical list (same x, increasing y)
const boxes = await page.locator('section.articles article.card').evaluateAll((els) =>
els.slice(0, Math.min(4, els.length)).map((el) => el.getBoundingClientRect())
);
expect(boxes.length).toBeGreaterThan(0);
const x0 = boxes[0].left;
for (let i = 1; i < boxes.length; i++) {
expect(Math.abs(boxes[i].left - x0)).toBeLessThan(6);
expect(boxes[i].top).toBeGreaterThan(boxes[i - 1].top);
}
});
});

188
tests/portfolio.test.ts Normal file
View file

@ -0,0 +1,188 @@
import { expect, test } from '@playwright/test';
test.describe('Portfolio page', () => {
test('has expected main heading', async ({ page }) => {
await page.goto('/portfolio');
await expect(page.getByRole('heading', { level: 1, name: 'Portfolio!' })).toBeVisible();
});
test('header/footer links hover: color becomes shellYellow', async ({ page }) => {
await page.goto('/portfolio');
const shellYellow = await page.evaluate(() => {
const probe = document.createElement('div');
probe.style.color = 'var(--shellYellow)';
document.body.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
});
const areas = [
'header[aria-label="header navigation"]',
'footer nav[aria-label="footer navigation"]',
];
for (const area of areas) {
const nav = page.locator(area);
await expect(nav).toBeVisible();
const link = nav.getByRole('link', { name: 'Portfolio', exact: true });
await expect(link).toBeVisible();
const before = await link.evaluate((el) => getComputedStyle(el as Element).color);
await link.hover();
const after = await link.evaluate((el) => getComputedStyle(el as Element).color);
expect(after).toBe(shellYellow);
expect(after).not.toBe(before);
}
});
test('current page (Portfolio) link is active in header and footer', async ({ page }) => {
await page.goto('/portfolio');
const areas = [
'header[aria-label="header navigation"]',
'footer nav[aria-label="footer navigation"]',
];
for (const area of areas) {
const nav = page.locator(area);
const portfolioLink = nav.getByRole('link', { name: 'Portfolio', exact: true });
await expect(portfolioLink).toBeVisible();
const isActive = await portfolioLink.evaluate((el) => (el as Element).classList.contains('active'));
expect(isActive).toBeTruthy();
}
});
test('tabs render and can switch between Personal and Professional', async ({ page }) => {
await page.goto('/portfolio');
// Prefer role-based tab selection; fall back to data attribute if role is not present
const personalTab = page.getByRole('tab', { name: 'Personal' }).or(page.locator('[data-tabs-trigger]', { hasText: 'Personal' }));
const professionalTab = page.getByRole('tab', { name: 'Professional' }).or(page.locator('[data-tabs-trigger]', { hasText: 'Professional' }));
await expect(personalTab).toBeVisible();
await expect(professionalTab).toBeVisible();
// Personal content visible by default (card heading exists)
const personalCardHeading = page.locator('.portfolio-picture h2', { hasText: 'Personal Website' }).first();
await expect(personalCardHeading).toBeVisible();
// Switch to Professional
await professionalTab.click();
// Professional content appears, personal may hide
const professionalCardHeading = page.locator('.portfolio-picture h2', { hasText: 'Mark Shellnut Architect' }).first();
await expect(professionalCardHeading).toBeVisible();
});
test('personal tab: key cards, images, and external links are accessible', async ({ page }) => {
await page.goto('/portfolio');
// Ensure on Personal tab
const personalTab = page.getByRole('tab', { name: 'Personal' }).or(page.locator('[data-tabs-trigger]', { hasText: 'Personal' }));
await personalTab.click();
// Headings (scoped to portfolio cards to avoid strict-mode conflicts)
await expect(page.locator('.portfolio-picture h2', { hasText: 'Personal Website' }).first()).toBeVisible();
await expect(page.locator('.portfolio-picture h2', { hasText: 'Wedding Website' }).first()).toBeVisible();
await expect(page.locator('.portfolio-picture h2', { hasText: 'Old Personal Website' }).first()).toBeVisible();
// Images by alt text
await expect(page.getByAltText("Picture of Bradley Shellnut's Personal Website")).toBeVisible();
await expect(page.getByAltText('Picture of NextJS Wedding Website')).toBeVisible();
await expect(page.getByAltText('Home Page of the old bradleyshellnut.com website')).toBeVisible();
// External links (use visible link names)
await expect(page.getByRole('link', { name: /GitHub repository/i }).first()).toBeVisible();
});
test('professional tab: card renders with external link', async ({ page }) => {
await page.goto('/portfolio');
const professionalTab = page.getByRole('tab', { name: 'Professional' }).or(page.locator('[data-tabs-trigger]', { hasText: 'Professional' }));
await professionalTab.click();
const professionalCard = page
.locator('.portfolio')
.filter({ has: page.locator('.portfolio-picture h2', { hasText: 'Mark Shellnut Architect' }) })
.first();
await expect(professionalCard).toBeVisible();
// Accessible name derived from aria-label in ExternalLink.svelte
await expect(
professionalCard.getByRole('link', { name: /Open\s+View Mark Shellnut Architect\s+externally/i })
).toBeVisible();
});
// Mirror header link presence from other pages
test('header navigation shows expected links', async ({ page }) => {
await page.goto('/portfolio');
const headerNav = page.locator('header[aria-label="header navigation"]');
await expect(headerNav).toBeVisible();
await expect(headerNav.getByRole('link', { name: 'Home', exact: true })).toBeVisible();
await expect(headerNav.getByRole('link', { name: 'About', exact: true })).toBeVisible();
await expect(headerNav.getByRole('link', { name: 'Portfolio', exact: true })).toBeVisible();
await expect(headerNav.getByRole('link', { name: 'Uses', exact: true })).toBeVisible();
});
// Mirror navigation flow via header (starting on /portfolio)
test('header navigation links go to correct routes (from /portfolio)', async ({ page }) => {
await page.goto('/portfolio');
const headerNav = page.locator('header[aria-label="header navigation"]');
await headerNav.getByRole('link', { name: 'About', exact: true }).click();
await expect(page).toHaveURL(/\/about\/?$/);
await headerNav.getByRole('link', { name: 'Uses', exact: true }).click();
await expect(page).toHaveURL(/\/uses\/?$/);
await headerNav.getByRole('link', { name: 'Home', exact: true }).click();
await expect(page).toHaveURL(/\/?$/);
await headerNav.getByRole('link', { name: 'Portfolio', exact: true }).click();
await expect(page).toHaveURL(/\/portfolio\/?$/);
});
// Mirror footer link presence from other pages
test('footer shows expected links', async ({ page }) => {
await page.goto('/portfolio');
const footerNav = page.getByRole('navigation', { name: 'footer navigation' });
await expect(footerNav).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Home', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'About', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Portfolio', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Uses', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Privacy', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Favorite Articles', exact: true })).toBeVisible();
});
// Mirror navigation via footer (starting on /portfolio)
test('footer navigation links go to correct routes (from /portfolio)', async ({ page }) => {
await page.goto('/portfolio');
const footerNav = page.getByRole('navigation', { name: 'footer navigation' });
await footerNav.getByRole('link', { name: 'Privacy', exact: true }).scrollIntoViewIfNeeded();
await footerNav.getByRole('link', { name: 'Privacy', exact: true }).click();
await expect(page).toHaveURL(/\/privacy\/?$/);
// Favorite Articles may route to /articles or /articles/1
const fav = footerNav.getByRole('link', { name: 'Favorite Articles', exact: true });
await fav.scrollIntoViewIfNeeded();
const href = await fav.getAttribute('href');
expect(href).toBeTruthy();
if (href) {
expect(href).toMatch(/\/articles(\/\d+)?\/?$/);
await page.goto(href);
await expect(page).toHaveURL(/\/articles(\/\d+)?\/?$/, { timeout: 15000 });
}
await footerNav.getByRole('link', { name: 'About', exact: true }).scrollIntoViewIfNeeded();
await footerNav.getByRole('link', { name: 'About', exact: true }).click();
await expect(page).toHaveURL(/\/about\/?$/);
await footerNav.getByRole('link', { name: 'Home', exact: true }).scrollIntoViewIfNeeded();
await footerNav.getByRole('link', { name: 'Home', exact: true }).click();
await expect(page).toHaveURL(/\/?$/);
});
});

191
tests/uses.test.ts Normal file
View file

@ -0,0 +1,191 @@
import { expect, test } from '@playwright/test';
test.describe('Uses page', () => {
test('has expected main heading', async ({ page }) => {
await page.goto('/uses');
await expect(page.getByRole('heading', { level: 1, name: '/Uses' })).toBeVisible();
});
test('header/footer links hover: color becomes shellYellow', async ({ page }) => {
await page.goto('/uses');
const shellYellow = await page.evaluate(() => {
const probe = document.createElement('div');
probe.style.color = 'var(--shellYellow)';
document.body.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
});
const areas = [
'header[aria-label="header navigation"]',
'footer nav[aria-label="footer navigation"]',
];
for (const area of areas) {
const nav = page.locator(area);
await expect(nav).toBeVisible();
const link = nav.getByRole('link', { name: 'Uses', exact: true });
await expect(link).toBeVisible();
const before = await link.evaluate((el) => getComputedStyle(el as Element).color);
await link.hover();
const after = await link.evaluate((el) => getComputedStyle(el as Element).color);
expect(after).toBe(shellYellow);
expect(after).not.toBe(before);
}
});
test('current page (Uses) link is active in header and footer', async ({ page }) => {
await page.goto('/uses');
const areas = [
'header[aria-label="header navigation"]',
'footer nav[aria-label="footer navigation"]',
];
for (const area of areas) {
const nav = page.locator(area);
const usesLink = nav.getByRole('link', { name: 'Uses', exact: true });
await expect(usesLink).toBeVisible();
const isActive = await usesLink.evaluate((el) => (el as Element).classList.contains('active'));
expect(isActive).toBeTruthy();
}
});
test('hero image visible with correct alt text', async ({ page }) => {
await page.goto('/uses');
await expect(page.getByAltText('Clean desk with Samsung monitor and Ducky Keyboard')).toBeVisible();
});
test('intro external links are accessible by aria-label-based names', async ({ page }) => {
await page.goto('/uses');
// ExternalLink.svelte sets accessible name as: "Open {ariaLabel|title|href} externally"
await expect(page.getByRole('link', { name: /Open\s+Wes Bos' Website\s+externally/i })).toBeVisible();
await expect(page.getByRole('link', { name: /Open\s+Wes Bos' Uses Page\s+externally/i })).toBeVisible();
await expect(page.getByRole('link', { name: /Open\s+Uses\.tech\s+externally/i })).toBeVisible();
});
test('sections and subsections render', async ({ page }) => {
await page.goto('/uses');
await expect(page.getByRole('heading', { level: 2, name: 'Development' })).toBeVisible();
// h3 subsections in development.svelte
const subsections = [
'Terminal & Shell Setup',
'Useful System Packages',
'Software',
'Useful Applications',
'Browsers',
];
for (const name of subsections) {
await expect(page.getByRole('heading', { level: 3, name })).toBeVisible();
}
});
test('a few key external links in Development section are present', async ({ page }) => {
await page.goto('/uses');
// Select within the Development section to be robust
const devSection = page.locator('section#uses-development');
await expect(devSection).toBeVisible();
const expectedLinks = [
/Open\s+Bradley Shellnut Computer Setup\s+externally/i,
/Open\s+Dotfiles\s+externally/i,
/Open\s+Tabby\s+externally/i,
/Open\s+Starship\s+externally/i,
/Open\s+ZimFW\s+externally/i,
/Open\s+Linux Brew\s+externally/i,
/Open\s+Homebrew\s+externally/i,
/Open\s+TLDR Man Pages\s+externally/i,
/Open\s+Trash-CLI\s+externally/i,
/Open\s+VSCodium\s+externally/i,
/Open\s+VSCode Extensions List\s+externally/i,
/Open\s+Sublime Text 3\s+externally/i,
/Open\s+Sublime Text Packages List\s+externally/i,
/Open\s+IntelliJ IDEA\s+externally/i,
/Open\s+IntelliJ Plugins\s+externally/i,
/Open\s+Bruno\s+externally/i,
/Open\s+Brave Browser\s+externally/i,
/Open\s+Firefox\s+externally/i,
];
for (const pattern of expectedLinks) {
await expect(devSection.getByRole('link', { name: pattern })).toBeVisible();
}
});
// Header nav presence
test('header navigation shows expected links', async ({ page }) => {
await page.goto('/uses');
const headerNav = page.locator('header[aria-label="header navigation"]');
await expect(headerNav).toBeVisible();
await expect(headerNav.getByRole('link', { name: 'Home', exact: true })).toBeVisible();
await expect(headerNav.getByRole('link', { name: 'About', exact: true })).toBeVisible();
await expect(headerNav.getByRole('link', { name: 'Portfolio', exact: true })).toBeVisible();
await expect(headerNav.getByRole('link', { name: 'Uses', exact: true })).toBeVisible();
});
// Header navigation flow (starting on /uses)
test('header navigation links go to correct routes (from /uses)', async ({ page }) => {
await page.goto('/uses');
const headerNav = page.locator('header[aria-label="header navigation"]');
await headerNav.getByRole('link', { name: 'About', exact: true }).click();
await expect(page).toHaveURL(/\/about\/?$/);
await headerNav.getByRole('link', { name: 'Portfolio', exact: true }).click();
await expect(page).toHaveURL(/\/portfolio\/?$/);
await headerNav.getByRole('link', { name: 'Home', exact: true }).click();
await expect(page).toHaveURL(/\/?$/);
await headerNav.getByRole('link', { name: 'Uses', exact: true }).click();
await expect(page).toHaveURL(/\/uses\/?$/);
});
// Footer link presence
test('footer shows expected links', async ({ page }) => {
await page.goto('/uses');
const footerNav = page.getByRole('navigation', { name: 'footer navigation' });
await expect(footerNav).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Home', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'About', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Portfolio', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Uses', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Privacy', exact: true })).toBeVisible();
await expect(footerNav.getByRole('link', { name: 'Favorite Articles', exact: true })).toBeVisible();
});
// Footer navigation flow (starting on /uses)
test('footer navigation links go to correct routes (from /uses)', async ({ page }) => {
await page.goto('/uses');
const footerNav = page.getByRole('navigation', { name: 'footer navigation' });
await footerNav.getByRole('link', { name: 'Privacy', exact: true }).scrollIntoViewIfNeeded();
await footerNav.getByRole('link', { name: 'Privacy', exact: true }).click();
await expect(page).toHaveURL(/\/privacy\/?$/);
const fav = footerNav.getByRole('link', { name: 'Favorite Articles', exact: true });
await fav.scrollIntoViewIfNeeded();
const href = await fav.getAttribute('href');
expect(href).toBeTruthy();
if (href) {
expect(href).toMatch(/\/articles(\/\d+)?\/?$/);
await page.goto(href);
await expect(page).toHaveURL(/\/articles(\/\d+)?\/?$/, { timeout: 15000 });
}
await footerNav.getByRole('link', { name: 'About', exact: true }).scrollIntoViewIfNeeded();
await footerNav.getByRole('link', { name: 'About', exact: true }).click();
await expect(page).toHaveURL(/\/about\/?$/);
await footerNav.getByRole('link', { name: 'Home', exact: true }).scrollIntoViewIfNeeded();
await footerNav.getByRole('link', { name: 'Home', exact: true }).click();
await expect(page).toHaveURL(/\/?$/);
});
});