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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.4.0] - 2026-02-26

### Added

#### Metadata ([#14](https://github.com/DeepanshKhurana/ode/issues/14))
- Social card preview system with OG image generation using `satori` and `@resvg/resvg-js`.
- 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`.
- Content-based meta descriptions: first 160 characters extracted from markdown content with formatting stripped.
- 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.
- Custom `bodyOfWork.description` field in `config.yaml` for `body-of-work` page social preview.
- `nginx` configuration templates in `nginx/` directory with setup instructions.
- OG images generated at 1200x630px with theme-specific styling.

#### 502 Error Page for `nginx` ([#19](https://github.com/DeepanshKhurana/ode/issues/19))
- Themed 502 error page generation with customizable text via `config.yaml`'s `redeployPage` section.
- The theme respects all settings e.g. `defaultMode`, `lowercase` and overrides enabled in `config.yaml`.
- 502 page served from persistent host location (survives container restarts).
- Configuration settings are available in the `nginx/` directory's base template.

<img width="640" alt="Ode's 502 error page template" src="https://github.com/user-attachments/assets/fae3f129-bb98-40d5-ab1e-5c96e0a524cd" />

_Here's what it looks like for my own site, fully customised. No more ugly 502 pages!_

### Changed

- Improved GitHub Actions deployment documentation in `WRITING.md` with SSH key setup guide. ([#18](https://github.com/DeepanshKhurana/ode/issues/18))

### Fixed

- Numeric values in `config.yaml` (e.g., `404`) now handled correctly. ([#17](https://github.com/DeepanshKhurana/ode/issues/17))

### Removed

- `react-helmet` dependency (using React 19 native meta tags).

## [1.2.9] - 2026-02-22

### Changed
Expand Down
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,7 @@ https://github.com/DeepanshKhurana/ode/blob/46873b31df3d4b02bbb375d4389173a1b6ac

#### nginx Configuration

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.

https://github.com/DeepanshKhurana/ode/blob/81c9c2916c5fade480a017b277be7eb1dc799cb4/nginx-template#L1-L32
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.

### From WordPress

Expand Down
126 changes: 105 additions & 21 deletions WRITING.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,88 @@ The container will build and serve your site. Restart to rebuild after content c
docker compose restart ode
```

## 4. (Optional) Auto-Deploy Content via GitHub Actions
## 4. GitHub Secrets (Required)

### SSH Key Setup

Generate an SSH key (ed25519 recommended):

```bash
ssh-keygen -t ed25519 -C "ode-deploy"
```

Press enter to accept defaults. When prompted for a passphrase, leave it empty for GitHub Actions.

This creates:

```
~/.ssh/id_ed25519
~/.ssh/id_ed25519.pub
```

### Add the Public Key to the Server

Copy the public key:

```bash
cat ~/.ssh/id_ed25519.pub
```

SSH into your server:

```bash
ssh root@your-server-ip
```

On the server:

```bash
mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keys
```

Paste the public key on its own line, then:

```bash
chmod 600 ~/.ssh/authorized_keys
```

### Test the SSH Connection

From your local machine:

```bash
ssh -i ~/.ssh/id_ed25519 root@your-server-ip
```

If this works without prompting for a password, SSH is configured correctly. Exit with `Ctrl + D`.

### Add Secrets to GitHub

In your content repo, go to **Settings → Secrets and variables → Actions** and add:

| Secret | Description |
|--------|-------------|
| `SSH_HOST` | Server IP or domain |
| `SSH_USER` | User with SSH + Docker access (e.g. `root`) |
| `SSH_KEY` | Full private key contents (including `-----BEGIN/END-----` lines) |
| `SSH_PORT` | SSH port (usually `22`) |

For `SSH_KEY`, paste the entire private key including:

```
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
```

## 5. Auto-Deploy Content via GitHub Actions

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

```yaml
name: Deploy Ode content
name: Deploy content

on:
push:
Expand All @@ -90,34 +166,42 @@ jobs:
deploy:
runs-on: ubuntu-latest

env:
PROJECT_NAME: your-project
APP_DIR: your-site
BACKUP_DIR: your-site.backup
SERVICE_NAME: ode
REPO_URL: git@github.com:YOUR_USER/YOUR_CONTENT_REPO.git

steps:
- name: Update content on server
- name: Destructive deploy and restart Ode
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
envs: PROJECT_NAME,APP_DIR,BACKUP_DIR,SERVICE_NAME,REPO_URL
script: |
cd /srv/my-ode-site
git pull
docker restart YOUR_CONTAINER_NAME
set -e
echo "⚠️ DESTRUCTIVE DEPLOY: /root/${APP_DIR} will be replaced (previous state kept at /root/${BACKUP_DIR})"
cd /root
if [ -d "${APP_DIR}" ]; then
rm -rf "${BACKUP_DIR}"
mv "${APP_DIR}" "${BACKUP_DIR}"
fi
git clone "${REPO_URL}" "${APP_DIR}"
cd "${APP_DIR}"
docker compose -p "${PROJECT_NAME}" up -d --force-recreate "${SERVICE_NAME}"
docker ps --format "table {{.Names}}\t{{.Status}}" | grep "${PROJECT_NAME}-${SERVICE_NAME}" || true
CONTAINER_NAME="${PROJECT_NAME}-${SERVICE_NAME}-1"
STATIC_DIR="/var/www/${PROJECT_NAME}-static"
[ ! -d "${STATIC_DIR}" ] && mkdir -p "${STATIC_DIR}"
sleep 5
docker cp "${CONTAINER_NAME}:/app/dist/generated/502.html" "${STATIC_DIR}/502.html" || echo "502 page copy skipped"
```

## 5. GitHub Secrets (Required)

Add these in your **content repo** under:

**Settings → Secrets and variables → Actions**

| Secret | Description |
|--------|-------------|
| `SSH_HOST` | Server IP or domain |
| `SSH_USER` | User with SSH + Docker access |
| `SSH_KEY` | Private SSH key |
| `SSH_PORT` | SSH port (usually 22) |

Ensure the *public* key is added to `~/.ssh/authorized_keys` on your server.
> [!WARNING]
> 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.

## 6. Alternative: Portainer Webhook

Expand Down
4 changes: 2 additions & 2 deletions build/calculate-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ try {
};

fs.writeFileSync(statsJsonPath, JSON.stringify(stats, null, 2));
console.log(`Stats calculated: ${wordsCount.toLocaleString()} words across ${piecesCount} pieces`);
console.log(`[stats]: ${wordsCount.toLocaleString()} words across ${piecesCount} pieces`);
} catch (error) {
console.error('Error calculating stats:', error);
console.error('[stats]: error calculating stats:', error);
process.exit(1);
}
3 changes: 2 additions & 1 deletion build/defaults/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ reader:
rss:
piecesLimit: 10
bodyOfWork:
order: descending
order: descending
description: "A chronological archive of all writings, organized by month and year."
18 changes: 9 additions & 9 deletions build/ensure-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,23 @@ const pagesDir = path.join(contentDir, 'pages');
const generatedDir = path.join(publicDir, 'generated');
const indexDir = path.join(generatedDir, 'index');

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

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

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

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

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

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

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

console.log('\nDefaults check complete.\n');
console.log('[defaults]: check complete');
91 changes: 91 additions & 0 deletions build/generate-502-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import { loadTheme, ThemeConfig } from './utils/theme-loader';

interface RedeployPageConfig {
title?: string;
message?: string;
submessage?: string;
refreshInterval?: number;
refreshNotice?: string;
}

interface Config {
site: {
name?: string;
title?: string;
};
theme?: string;
ui?: {
lowercase?: boolean;
theme?: {
defaultMode?: 'light' | 'dark';
};
};
redeployPage?: RedeployPageConfig;
}

const publicDir = path.join(process.cwd(), 'public');
const generatedDir = path.join(publicDir, 'generated');
const templateDir = path.join(process.cwd(), 'build', 'templates');

if (!fs.existsSync(generatedDir)) {
fs.mkdirSync(generatedDir, { recursive: true });
}

const configPath = path.join(publicDir, 'config.yaml');
const configContent = fs.readFileSync(configPath, 'utf-8');
const config = yaml.load(configContent) as Config;

const themeName = config.theme || 'journal';
const theme = loadTheme(themeName);

if (!theme) {
console.error(`[redeploy]: could not load theme: ${themeName}`);
process.exit(1);
}

const redeployConfig = config.redeployPage || {};
const useLowercase = config.ui?.lowercase || false;
const applyCase = (str: string) => useLowercase ? str.toLowerCase() : str;

const title = applyCase(redeployConfig.title || 'Just a moment...');
const message = applyCase(redeployConfig.message || "We're updating things behind the scenes.");
const submessage = applyCase(redeployConfig.submessage || 'Please refresh in a few seconds.');
const refreshInterval = redeployConfig.refreshInterval || 10;
const refreshNotice = applyCase((redeployConfig.refreshNotice || 'This page will refresh automatically in {interval} seconds.').replace('{interval}', String(refreshInterval)));
const siteName = config.site?.name || config.site?.title || 'Ode';

function generate502Page(theme: ThemeConfig): string {
const mode = config.ui?.theme?.defaultMode || 'light';
const colors = theme.colors[mode];

const templatePath = path.join(templateDir, '502.html');
let template = fs.readFileSync(templatePath, 'utf-8');

const replacements: Record<string, string> = {
title,
siteName,
fontUrl: theme.font.url,
fontFamily: theme.font.family,
bgColor: colors.background,
fgColor: colors.text,
accentColor: colors.primary,
mutedColor: colors.grey2,
message,
submessage,
refreshNotice,
refreshInterval: String(refreshInterval),
};

for (const [key, value] of Object.entries(replacements)) {
template = template.replace(new RegExp(`{{${key}}}`, 'g'), value);
}

return template;
}

const html = generate502Page(theme);
fs.writeFileSync(path.join(generatedDir, '502.html'), html);
console.log('[redeploy]: generated 502.html');
6 changes: 2 additions & 4 deletions build/generate-body-of-work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ if (bodyOfWorkOrder !== 'ascending' && bodyOfWorkOrder !== 'descending') {
throw new Error(`Invalid order "${bodyOfWorkOrder}" in config.yaml bodyOfWork.order. Must be "ascending" or "descending".`);
}

console.log(`Sorting body of work in ${bodyOfWorkOrder} order.`);
console.log(`[body-of-work]: sorting in ${bodyOfWorkOrder} order`);

const sortedKeys = Object.keys(grouped).sort((a, b) => {
const dateA = new Date(a);
Expand Down Expand Up @@ -91,6 +91,4 @@ sortedKeys.forEach(monthYear => {

fs.writeFileSync(bodyOfWorkPath, markdown);

console.log(`Body of work page generated successfully at ${bodyOfWorkPath}`);
console.log(`Total pieces: ${pieces.length}`);
console.log(`Months covered: ${sortedKeys.length}`);
console.log(`[body-of-work]: generated with ${pieces.length} pieces across ${sortedKeys.length} months`);
Loading