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
65 changes: 38 additions & 27 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
@@ -1,42 +1,53 @@
name: cd
name: validate-and-deploy

on:
push:
branches:
- main
branches: [main]
pull_request:

permissions:
contents: read

jobs:
cd:
validate:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
run_install: false

- name: Install Node.js
uses: actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'

- name: Install dependencies
run: pnpm install

# https://github.com/nuxt/content/issues/3249
- name: Rebuild native modules
run: pnpm rebuild better-sqlite3

- name: Generate static files
run: pnpm generate
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm test
- run: pnpm build
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm test:e2e --project=server
- run: pnpm generate
- run: pnpm test:output
- run: pnpm test:e2e --project=static
- uses: actions/upload-artifact@v4
with:
name: site
path: .output/public
include-hidden-files: true

- name: Deploy
uses: peaceiris/actions-gh-pages@v3
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: validate
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
name: site
path: public
- uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist
publish_dir: ./public
cname: colton.foo
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@ node_modules
.output
.env
dist
public/images/generated
playwright-report
test-results

.data/content/contents.sqlite
35 changes: 19 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# cmpadden.github.io

A personal website built with Nuxt 4, Vue, and Tailwind CSS. Routes are file-based in `pages/`; reusable layouts and UI live in `layouts/` and `components/`. Blog posts are Markdown files in `content/blog/`, parsed by `modules/content.ts` and served through the generated article API. Server endpoints and feed/sitemap routes are under `server/`.
A personal website built with Nuxt 4, Vue, and Tailwind CSS. Routes are file-based in `pages/`; reusable layouts and UI live in `layouts/` and `components/`. Blog posts are Markdown files in `content/blog/`, parsed by `modules/content.ts` and served through the generated article API. Server endpoints and feed/sitemap routes are under `server/`. Public types and site/route metadata live in `shared/`. The existing root-level Nuxt app structure is intentional; moving it under `app/` is optional organization, not a performance optimization.

## Setup

Expand All @@ -16,7 +16,13 @@ pnpm install
pnpm dev
```

The development site is available at http://localhost:3000.
The development site is available at http://localhost:3000. Drafts are excluded by default. To preview a draft by its slug locally, explicitly opt in:

```bash
NUXT_PREVIEW_DRAFTS=1 pnpm dev
```

Production builds always omit drafts, even if this environment variable is set. `modules/content/manifest.ts` validates frontmatter and owns publication policy; prerender routes, article APIs, feeds, sitemap, and legacy redirects all use it. `shared/site.ts` is the source of truth for the production origin and static page routes.

## Build and static generation

Expand All @@ -26,22 +32,19 @@ pnpm generate
pnpm preview
```

## Experiment thumbnails
Build, generate, and dev automatically create responsive image derivatives in `public/images/generated/` (ignored by Git). GitHub Pages deploys `.output/public`; it does not run a Nitro server. Legacy `/articles/…` and `/atom/` URLs therefore have generated HTML redirects as well as Nitro HTTP redirects. The feed is `/atom.xml` so static hosting serves it as XML. Robots and sitemap are generated from shared site metadata.

Experiment thumbnail context and output paths live in `data/experiments.mjs`. Generate an image from that metadata with an optional art-direction adjustment:
## Validation

```bash
OPENAI_API_KEY=... pnpm generate:experiment-thumbnail -- \
--experiment "photos.colton.boo" \
--direction "make the exposure bands diagonal and sparse"
pnpm typecheck
pnpm test # publication/SEO unit tests
pnpm exec playwright install chromium # once locally
pnpm build
pnpm test:e2e --project=server # production Nitro + browser/API tests
pnpm generate
pnpm test:output # generated publication/asset assertions
pnpm test:e2e --project=static # real static hosting, without SPA fallback
```

Use `--dry-run` to review the resolved prompt without calling the OpenAI Images API.

## Layout conventions

- `default`: site frame with Header and a scrollable content region.
- `light`: Header and centered page content, without the default frame.
- `empty`: no shared chrome, for standalone experiences.

Page-specific layout selection uses `definePageMeta({ layout: "light" })` or `layout: "empty"`.
CI runs this sequence on pull requests and main; only a successful main build is deployed. Browser tests mock audio/MIDI devices to check cleanup, pending permissions, and hot-plugging without requiring hardware. Real-device behavior should still be smoke-tested when changing the experiments.
13 changes: 12 additions & 1 deletion assets/css/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ code {
}

@layer base {
[id] { scroll-margin-top: 5rem; }

@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
transition-duration: 0s !important;
animation-duration: 0s !important;
}
}

:root {
color-scheme: light;
--site-background: #f3f6f6;
Expand All @@ -44,6 +54,7 @@ code {
@font-face {
font-family: Mafins;
src: url("/fonts/Mafins.otf");
font-display: swap;
}

@font-face {
Expand Down Expand Up @@ -87,7 +98,7 @@ code {
}

.noise-bg::before {
background-image: url("/images/noise.png");
background-image: url("/images/generated/noise.png");
background-repeat: repeat;
content: "";
filter: grayscale(1) contrast(var(--noise-contrast, 1.15));
Expand Down
35 changes: 21 additions & 14 deletions components/ExperimentCoverImage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,35 @@ const props = withDefaults(
{ loading: "lazy" },
);

const isLoaded = ref(false);
const image = ref<HTMLImageElement | null>(null);

onMounted(() => {
if (image.value?.complete) {
isLoaded.value = true;
}
});
const generatedBase = computed(() =>
props.src.startsWith("/images/previews/")
? props.src
.replace("/images/previews/", "/images/generated/")
.replace(/\.[^.]+$/, "")
: undefined,
);
const srcset = computed(() =>
generatedBase.value
? [320, 640, 960]
.map((width) => `${generatedBase.value}-${width}.webp ${width}w`)
.join(", ")
: undefined,
);
</script>

<template>
<div class="relative h-64 w-full overflow-hidden">
<img
ref="image"
class="h-full w-full object-cover grayscale transition-opacity duration-300 hover:grayscale-0"
:class="isLoaded ? 'opacity-100' : 'opacity-0'"
:src="props.src"
class="h-full w-full object-cover grayscale hover:grayscale-0"
:src="generatedBase ? `${generatedBase}-640.webp` : props.src"
:srcset="srcset"
sizes="(min-width: 1536px) 312px, (min-width: 1280px) 270px, (min-width: 1024px) 227px, (min-width: 768px) 206px, (min-width: 640px) 184px, calc(100vw - 88px)"
:alt="props.alt"
:loading="props.loading"
:fetchpriority="props.loading === 'eager' ? 'high' : 'auto'"
width="960"
height="960"
decoding="async"
@load="isLoaded = true"
@error="isLoaded = true"
/>
</div>
</template>
17 changes: 10 additions & 7 deletions components/Header.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<script setup lang="ts">
const menu = ref<HTMLDetailsElement | null>(null);
const route = useRoute();
const isActive = (path: string) =>
route.path.replace(/\/$/, "") === path.replace(/\/$/, "");

const closeMenu = () => {
menu.value?.removeAttribute("open");
Expand Down Expand Up @@ -58,7 +61,7 @@ onBeforeUnmount(() => {
@click="closeMenu"
:class="[
'text-gray-700 hover:bg-orange-500/50 hover:text-white dark:text-white',
$route.path === '/' ? 'bg-orange-500' : '',
isActive('/') ? 'bg-orange-500' : '',
'group flex w-full items-center px-3 py-3 text-sm',
]"
>
Expand All @@ -78,11 +81,11 @@ onBeforeUnmount(() => {
</NuxtLink>

<NuxtLink
to="/blog"
to="/blog/"
@click="closeMenu"
:class="[
'text-gray-700 hover:bg-orange-500/50 hover:text-white dark:text-white',
$route.path === '/blog' ? 'bg-orange-500' : '',
isActive('/blog/') ? 'bg-orange-500' : '',
'group flex w-full items-center px-3 py-3 text-sm',
]"
>
Expand All @@ -104,11 +107,11 @@ onBeforeUnmount(() => {
</NuxtLink>

<NuxtLink
to="/experiments"
to="/experiments/"
@click="closeMenu"
:class="[
'text-gray-700 hover:bg-orange-500/50 hover:text-white dark:text-white',
$route.path === '/experiments' ? 'bg-orange-500' : '',
isActive('/experiments/') ? 'bg-orange-500' : '',
'group flex w-full items-center px-3 py-3 text-sm',
]"
>
Expand All @@ -130,11 +133,11 @@ onBeforeUnmount(() => {
</NuxtLink>

<NuxtLink
to="/talks"
to="/talks/"
@click="closeMenu"
:class="[
'text-gray-700 hover:bg-orange-500/50 hover:text-white dark:text-white',
$route.path === '/talks' ? 'bg-orange-500' : '',
isActive('/talks/') ? 'bg-orange-500' : '',
'group flex w-full items-center px-3 py-3 text-sm',
]"
>
Expand Down
8 changes: 6 additions & 2 deletions components/blog/ArticleCard.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { BlogArticleSummary } from "../../modules/content/runtime/types/blog-article";
import type { BlogArticleSummary } from "../../shared/types/article";

withDefaults(
defineProps<{
Expand All @@ -20,7 +20,11 @@ const formatDate = (date: string) => {
</script>

<template>
<NuxtLink :to="article.path" class="group block h-full">
<NuxtLink
:to="article.path"
:prefetch-on="{ visibility: false, interaction: true }"
class="group block h-full"
>
<article
class="text-foreground grid h-full items-start gap-1"
:class="showDate ? 'grid-cols-1 sm:flex sm:gap-5' : 'grid-cols-1'"
Expand Down
29 changes: 29 additions & 0 deletions components/experiments/MidiStatus.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script setup lang="ts">
defineProps<{ inputs: MIDIInput[]; outputs: MIDIOutput[] }>();
</script>

<template>
<details class="my-3 rounded border border-current p-3 text-sm">
<summary class="cursor-pointer font-bold">MIDI Status</summary>
<div class="mt-3 grid gap-3 sm:grid-cols-2">
<div>
<h2 class="font-bold">Inputs</h2>
<p v-if="!inputs.length">No input devices detected.</p>
<ul v-else>
<li v-for="input in inputs" :key="input.id">
{{ input.manufacturer }} {{ input.name }}
</li>
</ul>
</div>
<div>
<h2 class="font-bold">Outputs</h2>
<p v-if="!outputs.length">No output devices detected.</p>
<ul v-else>
<li v-for="output in outputs" :key="output.id">
{{ output.manufacturer }} {{ output.name }}
</li>
</ul>
</div>
</div>
</details>
</template>
Loading
Loading