Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ The codebase has two distinct layers that should never blur:
| Layer | Location | Who owns it |
| ----------- | ------------------------------------------------- | ----------- |
| **App** | `src/` — routes, components, data loading, styles | Developer |
| **Content** | `oddments/`, `oddments.config.js` | Site owner |
| **Content** | `oddments/`, `oddments.config.js` | Site owner |

SvelteKit prebuilds every route at build time (fully static output to `build/`). There is no server at runtime.

Expand Down Expand Up @@ -61,14 +61,14 @@ The `/submit/` route is a generic form UI. It POSTs to `config.submitUrl` — an

- **Svelte 5 runes** everywhere: `$state`, `$derived`, `$effect`, `$props`, `$bindable`. No legacy Options API.
- Filter state lives in `+page.svelte`. Components receive values and emit changes via `bind:` props; they own no global state.
- All internal links must include the `base` import from `$app/paths` and prefix hrefs: `` href=`${base}/exhibit/${slug}/` ``.
- Local cover image paths need the same `base` prefix since `paths.base` is set in `svelte.config.js`.
- All internal links must use `resolve` from `$app/paths`, for example `href={resolve('/exhibit/my-slug/')}`.
- Local static asset paths must use `asset` from `$app/paths`, since `paths.base` is set in `svelte.config.js`.
- `FilterBar` and `TagCloud` accept a `show` prop; `+page.svelte` passes `data.config.showFilterBar` / `data.config.showTagCloud` directly — no `{#if}` wrappers at the call site.
- `FilterBar` owns the sort select. Pass `bind:sort` from `+page.svelte`.

### Adding a component test

Tests live next to the component file (`src/lib/Foo.test.ts`). The vitest config aliases `$lib` and mocks `$app/paths` (`base = ''`) so components can be imported in jsdom without the SvelteKit runtime.
Tests live next to the component file (`src/lib/Foo.test.ts`). The vitest config aliases `$lib` and mocks `$app/paths` so components can be imported in jsdom without the SvelteKit runtime.

```ts
import { render, screen } from '@testing-library/svelte'
Expand Down
9 changes: 9 additions & 0 deletions e2e/exhibit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ test.describe('exhibit detail page', () => {
await expect(page.locator('article.card').first()).toBeVisible()
})

test('category chip links to the catalog category filter', async ({ page }) => {
await page.goto('/exhibit/black-hack/')
const categoryChip = page.getByRole('link', { name: 'RPG' })
await expect(categoryChip).toBeVisible()
await categoryChip.click()
await expect(page).toHaveURL(/\?category=RPG/)
await expect(page.locator('article.card').first()).toBeVisible()
})

test('renders cover image when cover-image is set', async ({ page }) => {
await page.goto(firstExhibitUrl)
const img = page.getByRole('img').first()
Expand Down
9 changes: 5 additions & 4 deletions src/lib/ExhibitCard.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import type { Exhibit } from "./oddments.js";
import { base } from "$app/paths";
import { asset, resolve } from "$app/paths";
import { config } from "./catalog.js";

let { exhibit }: { exhibit: Exhibit } = $props();
Expand All @@ -16,9 +16,10 @@
const hasCover = $derived(Boolean(exhibit["cover-image"]));
const coverSrc = $derived(
exhibit["cover-image"]?.startsWith("/")
? `${base}${exhibit["cover-image"]}`
? asset(exhibit["cover-image"])
: (exhibit["cover-image"] ?? ""),
);
const exhibitHref = $derived(resolve(`/exhibit/${exhibit.slug}/`))
</script>

<article
Expand All @@ -28,7 +29,7 @@
>
{#if hasCover && orientation !== 'none'}
<a
href={`${base}/exhibit/${exhibit.slug}/`}
href={exhibitHref}
class="block overflow-hidden shrink-0"
aria-label={exhibit.name ?? exhibit.slug}
>
Expand All @@ -44,7 +45,7 @@
<!-- Card body -->
<div class="flex flex-col flex-1 p-4 gap-2">
<h2 class="font-bold text-base leading-snug">
<a href={`${base}/exhibit/${exhibit.slug}/`} class="hover:underline"
<a href={exhibitHref} class="hover:underline"
>{exhibit.name}</a
>
</h2>
Expand Down
2 changes: 1 addition & 1 deletion src/lib/ExhibitCard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,6 @@ test("local cover-image gets base prefix", () => {
exhibit: makeExhibit({ "cover-image": "/covers/game.webp", name: "Local Cover" }),
});
const img = screen.getByRole("img", { name: "Local Cover" });
// base is '' in test mock, so the src should be '/covers/game.webp'
// asset() returns the input path in the test mock.
expect(img).toHaveAttribute("src", "/covers/game.webp");
});
4 changes: 2 additions & 2 deletions src/lib/Footer.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { base } from '$app/paths'
import { resolve } from '$app/paths'
import { config as defaultConfig, type ResolvedConfig } from '$lib/catalog.js'
import Icon from './Icon.svelte'
import { normalizeSocialHref, socialDefinitions } from './socials.js'
Expand Down Expand Up @@ -43,7 +43,7 @@
{/if}

{#if config.showRss}
<a href="{base}/feed.xml" class="hover:opacity-80 inline-flex items-center gap-1">
<a href={resolve('/feed.xml')} class="hover:opacity-80 inline-flex items-center gap-1">
<Icon name="rss" size={14} />
<span>RSS Feed</span>
</a>
Expand Down
10 changes: 5 additions & 5 deletions src/lib/SiteHeader.svelte
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
<script lang="ts">
import { base } from '$app/paths'
import { asset, resolve } from '$app/paths'
import { config as defaultConfig, type ResolvedConfig } from '$lib/catalog.js'
import ThemeToggle from './ThemeToggle.svelte'

let { config = defaultConfig }: { config?: ResolvedConfig } = $props()

const logoSrc = $derived(
config.logo.startsWith('/') ? `${base}${config.logo}` : config.logo
config.logo.startsWith('/') ? asset(config.logo) : config.logo
)
const iconSrc = $derived(
config.icon.startsWith('/') ? `${base}${config.icon}` : config.icon
config.icon.startsWith('/') ? asset(config.icon) : config.icon
)
</script>

<header class="border-b border-surface-200-800 px-4 py-3 flex items-center justify-between gap-4">
<a
href="{base}/"
href={resolve('/')}
class="font-bold text-lg tracking-tight hover:opacity-80 transition-opacity inline-flex items-center gap-2 min-w-0"
aria-label="{config.title} home"
>
Expand All @@ -31,7 +31,7 @@

<div class="flex items-center gap-3">
{#if config.showSubmitForm}
<a href="{base}/submit/" class="text-sm opacity-60 hover:opacity-100 transition-opacity">
<a href={resolve('/submit/')} class="text-sm opacity-60 hover:opacity-100 transition-opacity">
Submit
</a>
{/if}
Expand Down
6 changes: 3 additions & 3 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
<script lang="ts">
import '../app.css';
import { base } from '$app/paths';
import { asset, resolve } from '$app/paths';
import { config } from '$lib/catalog.js';
import AppShell from '$lib/AppShell.svelte';

const { children } = $props();
</script>

<svelte:head>
<link rel="alternate" type="application/rss+xml" title={config.title} href="{base}/feed.xml" />
<link rel="alternate" type="application/rss+xml" title={config.title} href={resolve('/feed.xml')} />
{#if config.customCss}
<link rel="stylesheet" href="{base}{config.customCss}" />
<link rel="stylesheet" href={asset(config.customCss)} />
{/if}
</svelte:head>

Expand Down
7 changes: 4 additions & 3 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, untrack } from 'svelte'
import { base } from '$app/paths'
import { asset, resolve } from '$app/paths'
import type { Exhibit } from '$lib/oddments.js'
import type { ImageOrientation } from '$lib/config.js'
import CardGrid from '$lib/CardGrid.svelte'
Expand Down Expand Up @@ -110,8 +110,9 @@
return
}
try {
pagefind = await import(/* @vite-ignore */ `${base}/pagefind/pagefind.js`)
if (base) await pagefind!.options({ baseUrl: base })
pagefind = await import(/* @vite-ignore */ asset('/pagefind/pagefind.js'))
const baseUrl = resolve('/').replace(/\/$/, '')
if (baseUrl) await pagefind!.options({ baseUrl })
await pagefind!.init()
const available = await pagefind!.filters()
categories = Object.keys(available.category ?? {}).sort()
Expand Down
29 changes: 15 additions & 14 deletions src/routes/exhibit/[slug]/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { base } from '$app/paths'
import { asset, resolve } from '$app/paths'
import Icon from '$lib/Icon.svelte'
const { data } = $props()
const exhibit = $derived(data.exhibit)
Expand All @@ -21,9 +21,12 @@

const hasCover = $derived(Boolean(exhibit['cover-image']))
const coverSrc = $derived(
exhibit['cover-image']?.startsWith('/') ? `${base}${exhibit['cover-image']}` : (exhibit['cover-image'] ?? '')
exhibit['cover-image']?.startsWith('/') ? asset(exhibit['cover-image']) : (exhibit['cover-image'] ?? '')
)
const coverStyle = $derived(hasCover ? '' : `background: ${placeholderGradient(exhibit.name ?? exhibit.slug)};`)
const hasMetadataChips = $derived(
exhibit.category.length > 0 || Boolean(exhibit.genre) || Boolean(exhibit.license) || exhibit.tags.length > 0
)
</script>

<svelte:head>
Expand Down Expand Up @@ -90,12 +93,18 @@

<div class="flex flex-col gap-1.5">
{#if exhibit.author}
<span class="text-sm opacity-60">By <a href="{base}/?author={encodeURIComponent(exhibit.author)}" class="hover:underline">{exhibit.author}</a>{config.showCost && exhibit.cost ? ` · ${exhibit.cost}` : ''}</span>
<span class="text-sm opacity-60">By <a href={resolve(`/?author=${encodeURIComponent(exhibit.author)}`)} class="hover:underline">{exhibit.author}</a>{config.showCost && exhibit.cost ? ` · ${exhibit.cost}` : ''}</span>
{/if}
{#if exhibit.genre || exhibit.license}
{#if hasMetadataChips}
<div class="flex flex-wrap gap-1.5">
{#if exhibit.genre}<a href="{base}/?genre={encodeURIComponent(exhibit.genre)}" class="chip preset-tonal text-xs">{exhibit.genre}</a>{/if}
{#each exhibit.category as cat}
<a href={resolve(`/?category=${encodeURIComponent(cat)}`)} class="chip preset-tonal text-xs">{cat}</a>
{/each}
{#if exhibit.genre}<a href={resolve(`/?genre=${encodeURIComponent(exhibit.genre)}`)} class="chip preset-tonal text-xs">{exhibit.genre}</a>{/if}
{#if exhibit.license}<span class="chip preset-tonal text-xs">{exhibit.license}</span>{/if}
{#each exhibit.tags as tag}
<a href={resolve(`/?tag=${encodeURIComponent(tag)}`)} class="chip preset-tonal text-xs">{tag}</a>
{/each}
</div>
{/if}
</div>
Expand Down Expand Up @@ -137,14 +146,6 @@
</dl>
{/if}

{#if exhibit.tags.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each exhibit.tags as tag}
<a href="{base}/?tag={encodeURIComponent(tag)}" class="chip preset-tonal text-xs">{tag}</a>
{/each}
</div>
{/if}

{#if exhibit['source-url']}
<a
href={exhibit['source-url']}
Expand Down Expand Up @@ -191,7 +192,7 @@

<!-- Back link -->
<div class="mt-8 pt-4 border-t border-surface-200-800">
<a href="{base}/" class="text-sm opacity-60 hover:opacity-100 transition-opacity">← Back to catalog</a>
<a href={resolve('/')} class="text-sm opacity-60 hover:opacity-100 transition-opacity">← Back to catalog</a>
</div>

</div>
5 changes: 2 additions & 3 deletions src/stories/molecules/ExhibitCard.stories.svelte
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import type { ImageOrientation } from '$lib/config.js';

const { Story } = defineMeta({
title: 'Molecules/ExhibitCard',
tags: ['autodocs'],
argTypes: {
imageOrientation: {
control: 'radio',
options: ['landscape', 'portrait', 'none'] satisfies ImageOrientation[],
options: ['landscape', 'portrait', 'none'],
description: 'Controls whether card media is shown. Exhibit pages still use this for layout.',
},
},
args: {
imageOrientation: 'landscape' satisfies ImageOrientation,
imageOrientation: 'landscape',
},
});
</script>
Expand Down
5 changes: 2 additions & 3 deletions src/stories/pages/ExhibitDetail.stories.svelte
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import type { ImageOrientation } from '$lib/config.js';

const { Story } = defineMeta({
title: 'Pages/ExhibitDetail',
parameters: { layout: 'fullscreen' },
argTypes: {
imageOrientation: {
control: 'radio',
options: ['landscape', 'portrait', 'none'] satisfies ImageOrientation[],
options: ['landscape', 'portrait', 'none'],
description: 'Image layout. Mirrors the per-exhibit frontmatter field.',
},
},
args: {
imageOrientation: 'landscape' satisfies ImageOrientation,
imageOrientation: 'landscape',
},
});
</script>
Expand Down
7 changes: 7 additions & 0 deletions src/test/mocks/app-paths.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
export const base = ''
export const assets = ''
export function resolve(path: string) {
return path
}

export function asset(file: string) {
return file
}
9 changes: 8 additions & 1 deletion tsconfig.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,12 @@
"types": ["vitest/globals", "node"]
},
"exclude": [],
"include": ["src/**/*.test.ts", "src/test/**/*.ts"]
"include": [
".svelte-kit/ambient.d.ts",
".svelte-kit/non-ambient.d.ts",
".svelte-kit/types/**/*.d.ts",
"src/**/*.svelte",
"src/**/*.test.ts",
"src/test/**/*.ts"
]
}
8 changes: 5 additions & 3 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,8 @@ export default defineConfig({
configDir: path.join(dirname, '.storybook')
})],
optimizeDeps: {
// The dep-scan of story files fails on `import type` TS syntax, so Vite falls back to
// lazy discovery. Pre-including these prevents the mid-collection optimizer restarts
// that break the storybook browser tests on CI cold starts.
// Pre-including Storybook/theme packages keeps browser-test startup
// stable and avoids mid-collection optimizer restarts on cold caches.
include: [
'@storybook/addon-themes',
'@skeletonlabs/skeleton-svelte',
Expand All @@ -50,6 +49,9 @@ export default defineConfig({
browser: {
enabled: true,
headless: true,
api: {
host: '127.0.0.1',
},
provider: playwright({}),
instances: [{
browser: 'chromium'
Expand Down
Loading