Skip to content

Commit cb539e5

Browse files
Merge pull request #20 from DeepanshKhurana/development
v1.4.0: Social card previews, themed 502 page, QoL improvements, and misc fixes
2 parents 1b7246a + ec5730e commit cb539e5

39 files changed

Lines changed: 1791 additions & 242 deletions

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.4.0] - 2026-02-26
9+
10+
### Added
11+
12+
#### Metadata ([#14](https://github.com/DeepanshKhurana/ode/issues/14))
13+
- Social card preview system with OG image generation using `satori` and `@resvg/resvg-js`.
14+
- Pre-rendered meta pages for bots (WhatsApp, LinkedIn, Twitter, Facebook, etc.) with full `og:*` and `twitter:*` tags. Note that the tags work according to bot lists and would not be readily available on an online checker. However, `curl`ing the bots with a `User-Agent` e.g. `curl -H "User-Agent: WhatsApp/2.0"...` can be a good way to test. This works across `nginx` as well as `Vercel`. Configurations for both are provided in the repository as `nginx/` and `vercel.json`.
15+
- Content-based meta descriptions: first 160 characters extracted from markdown content with formatting stripped.
16+
- Reader URL bot support: `/reader/:collection?piece=:slug` serves appropriate meta pages to bots. The page is selected to be the piece the reader is currently reading.
17+
- Custom `bodyOfWork.description` field in `config.yaml` for `body-of-work` page social preview.
18+
- `nginx` configuration templates in `nginx/` directory with setup instructions.
19+
- OG images generated at 1200x630px with theme-specific styling.
20+
21+
#### 502 Error Page for `nginx` ([#19](https://github.com/DeepanshKhurana/ode/issues/19))
22+
- Themed 502 error page generation with customizable text via `config.yaml`'s `redeployPage` section.
23+
- The theme respects all settings e.g. `defaultMode`, `lowercase` and overrides enabled in `config.yaml`.
24+
- 502 page served from persistent host location (survives container restarts).
25+
- Configuration settings are available in the `nginx/` directory's base template.
26+
27+
<img width="640" alt="Ode's 502 error page template" src="https://github.com/user-attachments/assets/fae3f129-bb98-40d5-ab1e-5c96e0a524cd" />
28+
29+
_Here's what it looks like for my own site, fully customised. No more ugly 502 pages!_
30+
31+
### Changed
32+
33+
- Improved GitHub Actions deployment documentation in `WRITING.md` with SSH key setup guide. ([#18](https://github.com/DeepanshKhurana/ode/issues/18))
34+
35+
### Fixed
36+
37+
- Numeric values in `config.yaml` (e.g., `404`) now handled correctly. ([#17](https://github.com/DeepanshKhurana/ode/issues/17))
38+
39+
### Removed
40+
41+
- `react-helmet` dependency (using React 19 native meta tags).
42+
843
## [1.2.9] - 2026-02-22
944

1045
### Changed

README.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,7 @@ https://github.com/DeepanshKhurana/ode/blob/46873b31df3d4b02bbb375d4389173a1b6ac
132132

133133
#### nginx Configuration
134134

135-
If you are like me, you probably have your own server where you will need to handle SPA routing. If you are using nginx, a template is already provided.
136-
137-
https://github.com/DeepanshKhurana/ode/blob/81c9c2916c5fade480a017b277be7eb1dc799cb4/nginx-template#L1-L32
135+
If you are like me, you probably have your own server where you will need to handle SPA routing. If you are using nginx, configuration templates are provided in the [nginx/](https://github.com/DeepanshKhurana/ode/tree/main/nginx) directory.
138136

139137
### From WordPress
140138

WRITING.md

Lines changed: 113 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,88 @@ The container will build and serve your site. Restart to rebuild after content c
7575
docker compose restart ode
7676
```
7777

78-
## 4. (Optional) Auto-Deploy Content via GitHub Actions
78+
## 4. GitHub Secrets (Required)
79+
80+
### SSH Key Setup
81+
82+
Generate an SSH key (ed25519 recommended):
83+
84+
```bash
85+
ssh-keygen -t ed25519 -C "ode-deploy"
86+
```
87+
88+
Press enter to accept defaults. When prompted for a passphrase, leave it empty for GitHub Actions.
89+
90+
This creates:
91+
92+
```
93+
~/.ssh/id_ed25519
94+
~/.ssh/id_ed25519.pub
95+
```
96+
97+
### Add the Public Key to the Server
98+
99+
Copy the public key:
100+
101+
```bash
102+
cat ~/.ssh/id_ed25519.pub
103+
```
104+
105+
SSH into your server:
106+
107+
```bash
108+
ssh root@your-server-ip
109+
```
110+
111+
On the server:
112+
113+
```bash
114+
mkdir -p ~/.ssh
115+
chmod 700 ~/.ssh
116+
nano ~/.ssh/authorized_keys
117+
```
118+
119+
Paste the public key on its own line, then:
120+
121+
```bash
122+
chmod 600 ~/.ssh/authorized_keys
123+
```
124+
125+
### Test the SSH Connection
126+
127+
From your local machine:
128+
129+
```bash
130+
ssh -i ~/.ssh/id_ed25519 root@your-server-ip
131+
```
132+
133+
If this works without prompting for a password, SSH is configured correctly. Exit with `Ctrl + D`.
134+
135+
### Add Secrets to GitHub
136+
137+
In your content repo, go to **Settings → Secrets and variables → Actions** and add:
138+
139+
| Secret | Description |
140+
|--------|-------------|
141+
| `SSH_HOST` | Server IP or domain |
142+
| `SSH_USER` | User with SSH + Docker access (e.g. `root`) |
143+
| `SSH_KEY` | Full private key contents (including `-----BEGIN/END-----` lines) |
144+
| `SSH_PORT` | SSH port (usually `22`) |
145+
146+
For `SSH_KEY`, paste the entire private key including:
147+
148+
```
149+
-----BEGIN OPENSSH PRIVATE KEY-----
150+
...
151+
-----END OPENSSH PRIVATE KEY-----
152+
```
153+
154+
## 5. Auto-Deploy Content via GitHub Actions
79155

80156
Add this file in your **content repo** at `.github/workflows/deploy.yml`:
81157

82158
```yaml
83-
name: Deploy Ode content
159+
name: Deploy content
84160

85161
on:
86162
push:
@@ -90,34 +166,50 @@ jobs:
90166
deploy:
91167
runs-on: ubuntu-latest
92168

169+
env:
170+
PROJECT_NAME: your-project
171+
APP_DIR: your-site
172+
BACKUP_DIR: your-site.backup
173+
SERVICE_NAME: ode
174+
REPO_URL: git@github.com:YOUR_USER/YOUR_CONTENT_REPO.git
175+
93176
steps:
94-
- name: Update content on server
177+
- name: Destructive deploy and restart Ode
95178
uses: appleboy/ssh-action@v1.0.3
96179
with:
97180
host: ${{ secrets.SSH_HOST }}
98181
username: ${{ secrets.SSH_USER }}
99182
key: ${{ secrets.SSH_KEY }}
100-
port: ${{ secrets.SSH_PORT }}
183+
envs: PROJECT_NAME,APP_DIR,BACKUP_DIR,SERVICE_NAME,REPO_URL
101184
script: |
102-
cd /srv/my-ode-site
103-
git pull
104-
docker restart YOUR_CONTAINER_NAME
185+
set -e
186+
echo "⚠️ DESTRUCTIVE DEPLOY: /root/${APP_DIR} will be replaced (previous state kept at /root/${BACKUP_DIR})"
187+
cd /root
188+
if [ -d "${APP_DIR}" ]; then
189+
rm -rf "${BACKUP_DIR}"
190+
mv "${APP_DIR}" "${BACKUP_DIR}"
191+
fi
192+
git clone "${REPO_URL}" "${APP_DIR}"
193+
cd "${APP_DIR}"
194+
docker compose -p "${PROJECT_NAME}" up -d --force-recreate "${SERVICE_NAME}"
195+
docker ps --format "table {{.Names}}\t{{.Status}}" | grep "${PROJECT_NAME}-${SERVICE_NAME}" || true
196+
STATIC_DIR="/var/www/${PROJECT_NAME}-static"
197+
[ ! -d "${STATIC_DIR}" ] && mkdir -p "${STATIC_DIR}"
198+
GENERATED_502="${APP_DIR}/generated/502.html"
199+
echo "Waiting for 502 page to be generated..."
200+
for i in {1..60}; do
201+
if [ -f "${GENERATED_502}" ]; then
202+
cp "${GENERATED_502}" "${STATIC_DIR}/502.html"
203+
echo "502 page copied successfully"
204+
break
205+
fi
206+
echo "Build in progress... ($i/60)"
207+
sleep 2
208+
done
105209
```
106210
107-
## 5. GitHub Secrets (Required)
108-
109-
Add these in your **content repo** under:
110-
111-
**Settings → Secrets and variables → Actions**
112-
113-
| Secret | Description |
114-
|--------|-------------|
115-
| `SSH_HOST` | Server IP or domain |
116-
| `SSH_USER` | User with SSH + Docker access |
117-
| `SSH_KEY` | Private SSH key |
118-
| `SSH_PORT` | SSH port (usually 22) |
119-
120-
Ensure the *public* key is added to `~/.ssh/authorized_keys` on your server.
211+
> [!WARNING]
212+
> This workflow is destructive. On every push: the server directory is deleted, fresh-cloned, and only one backup is kept. Ensure all content lives in Git.
121213
122214
## 6. Alternative: Portainer Webhook
123215

build/calculate-stats.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ try {
3333
};
3434

3535
fs.writeFileSync(statsJsonPath, JSON.stringify(stats, null, 2));
36-
console.log(`Stats calculated: ${wordsCount.toLocaleString()} words across ${piecesCount} pieces`);
36+
console.log(`[stats]: ${wordsCount.toLocaleString()} words across ${piecesCount} pieces`);
3737
} catch (error) {
38-
console.error('Error calculating stats:', error);
38+
console.error('[stats]: error calculating stats:', error);
3939
process.exit(1);
4040
}

build/defaults/config.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@ reader:
3838
rss:
3939
piecesLimit: 10
4040
bodyOfWork:
41-
order: descending
41+
order: descending
42+
description: "A chronological archive of all writings, organized by month and year."

build/ensure-defaults.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,23 @@ const pagesDir = path.join(contentDir, 'pages');
1212
const generatedDir = path.join(publicDir, 'generated');
1313
const indexDir = path.join(generatedDir, 'index');
1414

15-
console.log('\nChecking for missing content...\n');
15+
console.log('[defaults]: checking for missing content...');
1616

1717
[contentDir, piecesDir, pagesDir, generatedDir, indexDir].forEach(dir => {
1818
if (!fs.existsSync(dir)) {
1919
fs.mkdirSync(dir, { recursive: true });
20-
console.warn(`WARNING: Directory missing: ${path.basename(dir)}/ — created`);
20+
console.warn(`[defaults]: directory missing: ${path.basename(dir)}/ — created`);
2121
}
2222
});
2323

2424
if (!fs.existsSync(introPath)) {
25-
console.warn('WARNING: intro.md missing — using default');
25+
console.warn('[defaults]: intro.md missing — using default');
2626
const defaultIntro = fs.readFileSync(path.join(defaultsDir, 'intro.md'), 'utf-8');
2727
fs.writeFileSync(introPath, defaultIntro);
2828
}
2929

3030
if (!fs.existsSync(configPath)) {
31-
console.warn('WARNING: config.yaml missing — using default');
31+
console.warn('[defaults]: config.yaml missing — using default');
3232
const defaultConfig = fs.readFileSync(path.join(defaultsDir, 'config.yaml'), 'utf-8');
3333
fs.writeFileSync(configPath, defaultConfig);
3434
}
@@ -39,7 +39,7 @@ const notFoundSlug = config?.pages?.notFound || 'obscured';
3939
const notFoundPath = path.join(pagesDir, `${notFoundSlug}.md`);
4040

4141
if (!fs.existsSync(notFoundPath)) {
42-
console.warn(`WARNING: 404 page "${notFoundSlug}.md" missing — using default`);
42+
console.warn(`[defaults]: 404 page "${notFoundSlug}.md" missing — using default`);
4343
const defaultNotFound = fs.readFileSync(path.join(defaultsDir, 'obscured.md'), 'utf-8');
4444
fs.writeFileSync(notFoundPath, defaultNotFound);
4545
}
@@ -49,7 +49,7 @@ const pieceFiles = fs.existsSync(piecesDir)
4949
: [];
5050

5151
if (pieceFiles.length === 0) {
52-
console.warn('WARNING: No pieces found — creating default piece "It\'s A Start"');
52+
console.warn('[defaults]: no pieces found — creating default piece "It\'s A Start"');
5353
const defaultPiece = fs.readFileSync(path.join(defaultsDir, 'its-a-start.md'), 'utf-8');
5454
const defaultPiecePath = path.join(piecesDir, 'its-a-start.md');
5555
fs.writeFileSync(defaultPiecePath, defaultPiece);
@@ -62,7 +62,7 @@ const pageFiles = fs.existsSync(pagesDir)
6262
const nonNotFoundPages = pageFiles.filter(f => f !== `${notFoundSlug}.md`);
6363

6464
if (nonNotFoundPages.length === 0) {
65-
console.warn('WARNING: No pages found — creating default "About" page');
65+
console.warn('[defaults]: no pages found — creating default "About" page');
6666
const defaultPage = fs.readFileSync(path.join(defaultsDir, 'about.md'), 'utf-8');
6767
const defaultPagePath = path.join(pagesDir, 'about.md');
6868
fs.writeFileSync(defaultPagePath, defaultPage);
@@ -75,6 +75,6 @@ Disallow:
7575
Sitemap: ${siteUrl}/generated/sitemap.xml
7676
`;
7777
fs.writeFileSync(robotsPath, robotsContent);
78-
console.log('Generated robots.txt');
78+
console.log('[defaults]: generated robots.txt');
7979

80-
console.log('\nDefaults check complete.\n');
80+
console.log('[defaults]: check complete');

build/generate-502-page.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import yaml from 'js-yaml';
4+
import { loadTheme, ThemeConfig } from './utils/theme-loader';
5+
6+
interface RedeployPageConfig {
7+
title?: string;
8+
message?: string;
9+
submessage?: string;
10+
refreshInterval?: number;
11+
refreshNotice?: string;
12+
}
13+
14+
interface Config {
15+
site: {
16+
name?: string;
17+
title?: string;
18+
};
19+
theme?: string;
20+
ui?: {
21+
lowercase?: boolean;
22+
theme?: {
23+
defaultMode?: 'light' | 'dark';
24+
};
25+
};
26+
redeployPage?: RedeployPageConfig;
27+
}
28+
29+
const publicDir = path.join(process.cwd(), 'public');
30+
const generatedDir = path.join(publicDir, 'generated');
31+
const templateDir = path.join(process.cwd(), 'build', 'templates');
32+
33+
if (!fs.existsSync(generatedDir)) {
34+
fs.mkdirSync(generatedDir, { recursive: true });
35+
}
36+
37+
const configPath = path.join(publicDir, 'config.yaml');
38+
const configContent = fs.readFileSync(configPath, 'utf-8');
39+
const config = yaml.load(configContent) as Config;
40+
41+
const themeName = config.theme || 'journal';
42+
const theme = loadTheme(themeName);
43+
44+
if (!theme) {
45+
console.error(`[redeploy]: could not load theme: ${themeName}`);
46+
process.exit(1);
47+
}
48+
49+
const redeployConfig = config.redeployPage || {};
50+
const useLowercase = config.ui?.lowercase || false;
51+
const applyCase = (str: string) => useLowercase ? str.toLowerCase() : str;
52+
53+
const title = applyCase(redeployConfig.title || 'Just a moment...');
54+
const message = applyCase(redeployConfig.message || "We're updating things behind the scenes.");
55+
const submessage = applyCase(redeployConfig.submessage || 'Please refresh in a few seconds.');
56+
const refreshInterval = redeployConfig.refreshInterval || 10;
57+
const refreshNotice = applyCase((redeployConfig.refreshNotice || 'This page will refresh automatically in {interval} seconds.').replace('{interval}', String(refreshInterval)));
58+
const siteName = config.site?.name || config.site?.title || 'Ode';
59+
60+
function generate502Page(theme: ThemeConfig): string {
61+
const mode = config.ui?.theme?.defaultMode || 'light';
62+
const colors = theme.colors[mode];
63+
64+
const templatePath = path.join(templateDir, '502.html');
65+
let template = fs.readFileSync(templatePath, 'utf-8');
66+
67+
const replacements: Record<string, string> = {
68+
title,
69+
siteName,
70+
fontUrl: theme.font.url,
71+
fontFamily: theme.font.family,
72+
bgColor: colors.background,
73+
fgColor: colors.text,
74+
accentColor: colors.primary,
75+
mutedColor: colors.grey2,
76+
message,
77+
submessage,
78+
refreshNotice,
79+
refreshInterval: String(refreshInterval),
80+
};
81+
82+
for (const [key, value] of Object.entries(replacements)) {
83+
template = template.replace(new RegExp(`{{${key}}}`, 'g'), value);
84+
}
85+
86+
return template;
87+
}
88+
89+
const html = generate502Page(theme);
90+
fs.writeFileSync(path.join(generatedDir, '502.html'), html);
91+
console.log('[redeploy]: generated 502.html');

0 commit comments

Comments
 (0)