Skip to content

Commit 01681c1

Browse files
committed
Add blog to getcmdr.com website
- Astro content collection with markdown posts and colocated images - Blog index with <!-- more --> excerpt support and "Read more" links - Individual post pages with BlogLayout (mirrors LegalLayout pattern) - OG image generation with Satori + resvg (1200x630 dark template) - RSS feed at /rss.xml via @astrojs/rss - Remark42 comments component (disabled in dev mode) - Remark42 Docker service in docker-compose.yml - Blog prose styles (code blocks, blockquotes, external link indicators) - Click-to-fullsize images, rehype-external-links for target="_blank" - Blog link in header nav and footer - 12 E2E tests covering index, posts, excerpts, RSS, OG images, meta tags - Docs: writing blog posts guide, deploying Remark42 guide, website CLAUDE.md
1 parent 3835866 commit 01681c1

27 files changed

Lines changed: 1416 additions & 2 deletions

apps/website/CLAUDE.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Website (getcmdr.com)
2+
3+
Marketing site and blog for Cmdr. Astro + Tailwind v4, statically built, deployed via Docker + Caddy.
4+
5+
## Stack
6+
7+
- **Astro** — static site generator with content collections
8+
- **Tailwind v4** — CSS-first config in `src/styles/global.css`
9+
- **Playwright** — E2E tests in `e2e/`
10+
- **Docker + Caddy** — production deployment (see `docs/guides/deploy-website.md`)
11+
12+
## Blog
13+
14+
Blog posts live in `src/content/blog/{slug}/index.md` with colocated images.
15+
16+
### Key files
17+
18+
| File | Purpose |
19+
| --------------------------------------- | ---------------------------------------------------------- |
20+
| `src/content.config.ts` | Blog collection schema (title, date, description, cover) |
21+
| `src/layouts/BlogLayout.astro` | Post page layout (date, title, description, prose styles) |
22+
| `src/styles/blog-prose.css` | Shared prose styles for blog content |
23+
| `src/pages/blog/index.astro` | Blog index — excerpts with "Read more" links, newest first |
24+
| `src/pages/blog/[slug].astro` | Individual post page with comments |
25+
| `src/pages/og/[slug].png.ts` | OG image generation (Satori + resvg) |
26+
| `src/pages/rss.xml.ts` | RSS feed |
27+
| `src/components/Remark42Comments.astro` | Comment widget (disabled in dev) |
28+
| `src/components/BlogImageClick.astro` | Click-to-fullsize for blog images |
29+
30+
### OG images
31+
32+
Generated at build time with Satori (JSX to SVG) and resvg (SVG to PNG). Colors are hardcoded because Satori doesn't
33+
support CSS variables — keep them in sync with `global.css` theme values.
34+
35+
### Comments (Remark42)
36+
37+
Self-hosted at `comments.getcmdr.com`. Disabled in dev mode (shows a placeholder instead). See
38+
`docs/guides/deploying-remark42.md` for setup.
39+
40+
### Adding a new post
41+
42+
See `docs/guides/writing-blog-posts.md`.
43+
44+
## Patterns
45+
46+
- **Layouts**: `Layout.astro` (base), `BlogLayout.astro` (posts), `LegalLayout.astro` (terms, privacy)
47+
- **CSS variables**: defined in `src/styles/global.css` under `@theme`. Use them everywhere.
48+
- **External links**: `rehype-external-links` auto-adds `target="_blank" rel="noopener noreferrer"`
49+
- **RSS autodiscovery**: `<link>` tag in `Layout.astro`
50+
51+
## Gotchas
52+
53+
- The `@ts-expect-error` in `astro.config.mjs` is for a Vite version mismatch between Astro and Tailwind. It doesn't
54+
affect the build.
55+
- `site` must be set in `astro.config.mjs` for RSS and OG image URLs to work.
56+
- Font files for OG image generation (`inter-400.ttf`, `inter-700.ttf`) live in `public/fonts/`.

apps/website/astro.config.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
// @ts-check
22
import { defineConfig } from 'astro/config'
33
import tailwindcss from '@tailwindcss/vite'
4+
import rehypeExternalLinks from 'rehype-external-links'
45

56
// https://astro.build/config
67
export default defineConfig({
8+
site: 'https://getcmdr.com',
79
output: 'static',
810
server: {
911
port: parseInt(process.env.PORT || '4321'),
1012
},
13+
markdown: {
14+
shikiConfig: { theme: 'github-dark' },
15+
rehypePlugins: [[rehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }]],
16+
},
1117
vite: {
1218
server: {
1319
strictPort: true,

apps/website/docker-compose.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,27 @@ services:
88
networks:
99
- proxy-net
1010

11+
# Caddy route needed: comments.getcmdr.com -> remark42:8080
12+
remark42:
13+
image: umputun/remark42:latest
14+
container_name: remark42
15+
restart: unless-stopped
16+
volumes:
17+
- remark42-data:/srv/var
18+
environment:
19+
- REMARK_URL=https://comments.getcmdr.com
20+
- SECRET=${REMARK42_SECRET}
21+
- SITE=getcmdr
22+
- AUTH_GOOGLE_CID=${AUTH_GOOGLE_CID}
23+
- AUTH_GOOGLE_CSEC=${AUTH_GOOGLE_CSEC}
24+
- AUTH_GITHUB_CID=${AUTH_GITHUB_CID}
25+
- AUTH_GITHUB_CSEC=${AUTH_GITHUB_CSEC}
26+
networks:
27+
- proxy-net
28+
1129
networks:
1230
proxy-net:
1331
external: true
32+
33+
volumes:
34+
remark42-data:

apps/website/e2e/blog.spec.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
test.describe('Blog', () => {
4+
test('blog index loads with correct title', async ({ page }) => {
5+
await page.goto('/blog')
6+
await expect(page).toHaveTitle('Blog — Cmdr')
7+
})
8+
9+
test('blog index lists posts with dates', async ({ page }) => {
10+
await page.goto('/blog')
11+
const article = page.locator('article').first()
12+
await expect(article).toBeVisible()
13+
await expect(article.locator('time')).toBeVisible()
14+
})
15+
16+
test('blog index shows excerpt, not full post', async ({ page }) => {
17+
await page.goto('/blog')
18+
const article = page.locator('article').first()
19+
// Excerpt content should be visible
20+
await expect(article.locator('.blog-content')).toContainText('What to expect')
21+
// Content after the <!-- more --> marker should not appear
22+
await expect(article.locator('.blog-content')).not.toContainText('A quick look at Cmdr')
23+
})
24+
25+
test('blog index has "Read more" link to full post', async ({ page }) => {
26+
await page.goto('/blog')
27+
const readMore = page.locator('.read-more-link').first()
28+
await expect(readMore).toBeVisible()
29+
await expect(readMore).toHaveAttribute('href', '/blog/hello-world')
30+
await readMore.click()
31+
await expect(page).toHaveURL(/\/blog\/hello-world/)
32+
await expect(page.locator('h1')).toContainText('Hello, world')
33+
})
34+
35+
test('post title links to individual post page', async ({ page }) => {
36+
await page.goto('/blog')
37+
const postLink = page.locator('article a[href*="/blog/"]').first()
38+
await expect(postLink).toBeVisible()
39+
await postLink.click()
40+
await expect(page).toHaveURL(/\/blog\/hello-world/)
41+
await expect(page.locator('h1')).toContainText('Hello, world')
42+
})
43+
44+
test('individual post shows full content including sections after excerpt', async ({ page }) => {
45+
await page.goto('/blog/hello-world')
46+
// Both excerpt and post-excerpt content should be visible
47+
await expect(page.locator('.blog-content')).toContainText('What to expect')
48+
await expect(page.locator('.blog-content')).toContainText('A quick look at Cmdr')
49+
await expect(page.locator('.blog-content')).toContainText('Built with modern tools')
50+
})
51+
52+
test('individual post shows date and description', async ({ page }) => {
53+
await page.goto('/blog/hello-world')
54+
await expect(page.locator('main time')).toBeVisible()
55+
await expect(page.locator('main header p')).toContainText('Welcome to the Cmdr blog')
56+
})
57+
58+
test('individual post has correct meta tags', async ({ page }) => {
59+
await page.goto('/blog/hello-world')
60+
const ogImage = page.locator('meta[property="og:image"]')
61+
await expect(ogImage).toHaveAttribute('content', /\/og\/hello-world\.png/)
62+
const description = page.locator('meta[name="description"]')
63+
await expect(description).toHaveAttribute('content', /Welcome to the Cmdr blog/)
64+
const ogTitle = page.locator('meta[property="og:title"]')
65+
await expect(ogTitle).toHaveAttribute('content', /Hello, world/)
66+
})
67+
68+
test('individual post has comments section', async ({ page }) => {
69+
await page.goto('/blog/hello-world')
70+
await expect(page.locator('.comments-section')).toBeVisible()
71+
await expect(page.getByText('Comments')).toBeVisible()
72+
})
73+
74+
test('external links open in new tabs', async ({ page }) => {
75+
await page.goto('/blog/hello-world')
76+
const externalLinks = page.locator('.blog-content a[target="_blank"]')
77+
const count = await externalLinks.count()
78+
expect(count).toBeGreaterThan(0)
79+
for (let i = 0; i < count; i++) {
80+
await expect(externalLinks.nth(i)).toHaveAttribute('rel', /noopener/)
81+
}
82+
})
83+
84+
test('RSS feed returns valid XML with post data', async ({ request }) => {
85+
const response = await request.get('/rss.xml')
86+
expect(response.status()).toBe(200)
87+
expect(response.headers()['content-type']).toContain('xml')
88+
const body = await response.text()
89+
expect(body).toContain('<title>Cmdr blog</title>')
90+
expect(body).toContain('Hello, world')
91+
expect(body).toContain('/blog/hello-world/')
92+
})
93+
94+
test('OG image returns PNG', async ({ request }) => {
95+
const response = await request.get('/og/hello-world.png')
96+
expect(response.status()).toBe(200)
97+
expect(response.headers()['content-type']).toContain('png')
98+
})
99+
100+
test('navigation has Blog link', async ({ page }) => {
101+
await page.goto('/')
102+
const blogLink = page.getByRole('navigation').getByRole('link', { name: 'Blog' })
103+
await expect(blogLink).toBeVisible()
104+
})
105+
})

apps/website/eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export default tseslint.config(
2424
prettier,
2525
},
2626
languageOptions: {
27+
parser: tseslint.parser,
2728
ecmaVersion: 'latest',
2829
sourceType: 'module',
2930
globals: {

apps/website/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@
1818
"test:lighthouse": "lhci autorun"
1919
},
2020
"dependencies": {
21+
"@astrojs/rss": "4.0.15",
22+
"@resvg/resvg-js": "2.6.2",
2123
"@tailwindcss/vite": "^4.1.18",
2224
"astro": "^5.17.1",
2325
"marked": "^17.0.1",
26+
"rehype-external-links": "3.0.0",
27+
"satori": "0.19.2",
28+
"sharp": "^0.34.5",
2429
"tailwindcss": "^4.1.18"
2530
},
2631
"devDependencies": {
317 KB
Binary file not shown.
319 KB
Binary file not shown.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!-- Adds click-to-open-fullsize behavior to all images inside `.blog-content` containers -->
2+
<script>
3+
document.addEventListener('astro:page-load', () => {
4+
document.querySelectorAll<HTMLImageElement>('.blog-content img').forEach((img) => {
5+
img.addEventListener('click', () => {
6+
if (img.src) window.open(img.src, '_blank')
7+
})
8+
})
9+
})
10+
</script>

apps/website/src/components/Footer.astro

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ const currentYear = new Date().getFullYear()
5151
<div>
5252
<p class="mb-3 text-sm font-semibold text-[var(--color-text-primary)]">Resources</p>
5353
<ul class="space-y-2 text-sm">
54+
<li>
55+
<a
56+
href="/blog"
57+
class="text-[var(--color-text-secondary)] underline decoration-[var(--color-border)] underline-offset-2 transition-colors hover:text-[var(--color-text-primary)] hover:decoration-[var(--color-text-tertiary)]"
58+
>
59+
Blog
60+
</a>
61+
</li>
5462
<li>
5563
<a
5664
href="https://github.com/vdavid/cmdr"

0 commit comments

Comments
 (0)