From f630d2ef3b3ac1e9c618b51393951d74740e84ff Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Thu, 26 Feb 2026 02:25:10 +0530 Subject: [PATCH 01/13] feat: add themed 502 error page with generated assets reorganization --- build/generate-502-page.ts | 91 ++++++++++++++++++++++++++++++++ build/templates/502.html | 76 ++++++++++++++++++++++++++ build/utils/theme-loader.ts | 69 ++++++++++++++++++++++++ nginx-template | 47 +++++++++++++++-- package-lock.json | 85 ++--------------------------- package.json | 8 +-- public/config.yaml | 6 +++ public/robots.txt | 2 +- src/components/Header/Header.jsx | 15 +++--- vercel.json | 45 +++++++++++++++- 10 files changed, 341 insertions(+), 103 deletions(-) create mode 100644 build/generate-502-page.ts create mode 100644 build/templates/502.html create mode 100644 build/utils/theme-loader.ts diff --git a/build/generate-502-page.ts b/build/generate-502-page.ts new file mode 100644 index 0000000..8952258 --- /dev/null +++ b/build/generate-502-page.ts @@ -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(`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 = { + 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('Generated 502.html'); diff --git a/build/templates/502.html b/build/templates/502.html new file mode 100644 index 0000000..06cbc9b --- /dev/null +++ b/build/templates/502.html @@ -0,0 +1,76 @@ + + + + + + {{title}} | {{siteName}} + + + + + +
+

{{title}}

+
+

{{message}}

+

{{submessage}}

+

{{refreshNotice}}

+
+ + diff --git a/build/utils/theme-loader.ts b/build/utils/theme-loader.ts new file mode 100644 index 0000000..d2fb854 --- /dev/null +++ b/build/utils/theme-loader.ts @@ -0,0 +1,69 @@ +import fs from 'fs'; +import path from 'path'; + +export interface ThemeConfig { + font: { + family: string; + url: string; + fallback: string; + }; + colors: { + light: { + primary: string; + secondary: string; + grey: string; + grey2: string; + text: string; + highlight: string; + background: string; + }; + dark: { + primary: string; + secondary: string; + grey: string; + grey2: string; + text: string; + highlight: string; + background: string; + }; + }; +} + +const themesDir = path.join(process.cwd(), 'src', 'assets', 'themes'); + +export function loadTheme(themeName: string): ThemeConfig | null { + const themePath = path.join(themesDir, `${themeName}.js`); + + if (!fs.existsSync(themePath)) { + console.error(`Theme file not found: ${themePath}`); + return null; + } + + try { + const themeContent = fs.readFileSync(themePath, 'utf-8'); + + const defaultExportMatch = themeContent.match(/export\s+default\s+({[\s\S]*});?\s*$/); + if (!defaultExportMatch) { + console.error(`Could not parse theme export in: ${themePath}`); + return null; + } + + const themeObjectStr = defaultExportMatch[1]; + const theme = eval(`(${themeObjectStr})`) as ThemeConfig; + + return theme; + } catch (error) { + console.error(`Error loading theme ${themeName}:`, error); + return null; + } +} + +export function getAvailableThemes(): string[] { + if (!fs.existsSync(themesDir)) { + return []; + } + + return fs.readdirSync(themesDir) + .filter(file => file.endsWith('.js')) + .map(file => file.replace('.js', '')); +} diff --git a/nginx-template b/nginx-template index 93461ec..9cf4506 100644 --- a/nginx-template +++ b/nginx-template @@ -2,8 +2,26 @@ server { listen 80; server_name your-domain.com; + # RSS feed + location = /feed.xml { + proxy_pass http://localhost:PORT/generated/feed.xml; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location = /feed/ { - proxy_pass http://localhost:PORT/feed.xml; + proxy_pass http://localhost:PORT/generated/feed.xml; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Sitemap + location = /sitemap.xml { + proxy_pass http://localhost:PORT/generated/sitemap.xml; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -11,14 +29,15 @@ server { } location = /sitemap/ { - proxy_pass http://localhost:PORT/sitemap.xml; + proxy_pass http://localhost:PORT/generated/sitemap.xml; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - location = /robots { + # Robots + location = /robots.txt { proxy_pass http://localhost:PORT/robots.txt; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -26,7 +45,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - location = /robots.txt { + location = /robots/ { proxy_pass http://localhost:PORT/robots.txt; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -34,6 +53,24 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # Generated assets (meta images, 502 page, etc.) + location /generated/ { + proxy_pass http://localhost:PORT/generated/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Custom 502 error page (served from persistent location outside container) + # Replace YOUR_PROJECT_NAME with your project name + error_page 502 /502.html; + location = /502.html { + root /var/www/YOUR_PROJECT_NAME-static; + internal; + } + + # Everything else location / { proxy_pass http://localhost:PORT; proxy_set_header Host $host; @@ -41,11 +78,11 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - # SPA routing: proxy_intercept_errors on; error_page 404 = /index.html; } + # SPA fallback location = /index.html { proxy_pass http://localhost:PORT; proxy_set_header Host $host; diff --git a/package-lock.json b/package-lock.json index c8f99bc..d115546 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,18 @@ { "name": "ode", - "version": "0.0.1", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ode", - "version": "0.0.1", + "version": "1.3.0", "dependencies": { "front-matter": "^4.0.2", "js-yaml": "^4.1.1", "marked": "^17.0.1", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-helmet": "^6.1.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.9.6", "sass": "^1.94.2", @@ -25,7 +24,6 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", - "@types/react-helmet": "^6.1.11", "@vitejs/plugin-react": "^5.1.1", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", @@ -1717,16 +1715,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/react-helmet": { - "version": "6.1.11", - "resolved": "https://registry.npmjs.org/@types/react-helmet/-/react-helmet-6.1.11.tgz", - "integrity": "sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2874,6 +2862,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -2992,18 +2981,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3706,15 +3683,6 @@ "dev": true, "license": "MIT" }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/obug": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.0.tgz", @@ -3895,17 +3863,6 @@ "node": ">= 0.8.0" } }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -3947,42 +3904,6 @@ "react": "^19.2.0" } }, - "node_modules/react-fast-compare": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", - "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", - "license": "MIT" - }, - "node_modules/react-helmet": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz", - "integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4.1.1", - "prop-types": "^15.7.2", - "react-fast-compare": "^3.1.1", - "react-side-effect": "^2.1.0" - }, - "peerDependencies": { - "react": ">=16.3.0" - } - }, - "node_modules/react-helmet/node_modules/react-side-effect": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz", - "integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==", - "license": "MIT", - "peerDependencies": { - "react": "^16.3.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", diff --git a/package.json b/package.json index 87d79fa..785cc8d 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,20 @@ { "name": "ode", "private": true, - "version": "0.0.1", + "version": "1.3.0", "type": "module", "scripts": { "dev": "vite", "build": "npm run build:index && vite build", - "build:index": "vite-node build/ensure-defaults.ts && vite-node build/index-pieces.ts && vite-node build/index-pages.ts && vite-node build/paginate-pieces.ts && vite-node build/calculate-stats.ts && vite-node build/generate-rss.ts && vite-node build/generate-sitemap.ts && vite-node build/generate-body-of-work.ts", + "build:index": "vite-node build/ensure-defaults.ts && vite-node build/generate-502-page.ts && vite-node build/index-pieces.ts && vite-node build/index-pages.ts && vite-node build/paginate-pieces.ts && vite-node build/calculate-stats.ts && vite-node build/generate-rss.ts && vite-node build/generate-sitemap.ts && vite-node build/generate-body-of-work.ts", "build:pieces": "vite-node build/index-pieces.ts", "build:pages": "vite-node build/index-pages.ts", "build:paginate": "vite-node build/paginate-pieces.ts", "build:stats": "vite-node build/calculate-stats.ts", "build:rss": "vite-node build/generate-rss.ts", "build:sitemap": "vite-node build/generate-sitemap.ts", + "build:502": "vite-node build/generate-502-page.ts", + "build:meta": "vite-node build/generate-meta-images.ts", "lint": "eslint .", "preview": "vite preview" }, @@ -22,7 +24,6 @@ "marked": "^17.0.1", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-helmet": "^6.1.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.9.6", "sass": "^1.94.2", @@ -34,7 +35,6 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", - "@types/react-helmet": "^6.1.11", "@vitejs/plugin-react": "^5.1.1", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/public/config.yaml b/public/config.yaml index 81a2c3b..e81f1dc 100644 --- a/public/config.yaml +++ b/public/config.yaml @@ -47,3 +47,9 @@ rss: piecesLimit: 10 bodyOfWork: order: ascending +redeployPage: + title: "Just a moment..." + message: "Something new is coming along." + submessage: "Please refresh in a few seconds." + refreshInterval: 10 + refreshNotice: "This page will refresh automatically in {interval} seconds." diff --git a/public/robots.txt b/public/robots.txt index 8940ec9..bb2fcc2 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,3 +1,3 @@ User-agent: * Disallow: -Sitemap: https://demo.ode.dimwit.me/generated/sitemap.xml +Sitemap: https://demo.ode.dimwit.me/sitemap.xml diff --git a/src/components/Header/Header.jsx b/src/components/Header/Header.jsx index 04347a7..4781296 100644 --- a/src/components/Header/Header.jsx +++ b/src/components/Header/Header.jsx @@ -1,6 +1,5 @@ import { Link, useLocation, useParams } from "react-router-dom"; import { useEffect, useState } from "react"; -import { Helmet } from "react-helmet"; import LampToggle from "../LampToggle/LampToggle"; import { makeAppTitle } from "../../utils/makeAppTitle"; @@ -39,14 +38,12 @@ function Header({ config }) { return ( <> - - {makeAppTitle(site)} - - - - - - + {makeAppTitle(site)} + + + + + {isCollection ? (
diff --git a/vercel.json b/vercel.json index 8cbb440..f224547 100644 --- a/vercel.json +++ b/vercel.json @@ -1,13 +1,21 @@ { "buildCommand": "npm run build", "rewrites": [ + { + "source": "/feed.xml", + "destination": "/generated/feed.xml" + }, + { + "source": "/sitemap.xml", + "destination": "/generated/sitemap.xml" + }, { "source": "/feed/", - "destination": "/feed.xml" + "destination": "/generated/feed.xml" }, { "source": "/sitemap/", - "destination": "/sitemap.xml" + "destination": "/generated/sitemap.xml" }, { "source": "/robots", @@ -21,5 +29,38 @@ "source": "/(.*)", "destination": "/index.html" } + ], + "headers": [ + { + "source": "/generated/meta-images/(.*)", + "headers": [ + { + "key": "Content-Type", + "value": "image/svg+xml" + }, + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + }, + { + "source": "/generated/feed.xml", + "headers": [ + { + "key": "Content-Type", + "value": "application/xml" + } + ] + }, + { + "source": "/generated/sitemap.xml", + "headers": [ + { + "key": "Content-Type", + "value": "application/xml" + } + ] + } ] } \ No newline at end of file From 04cb21246a54f8f27fbdee2729a465a2aff8e16d Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Thu, 26 Feb 2026 02:25:58 +0530 Subject: [PATCH 02/13] fix: handle numeric values in config exclude lists --- build/index-pages.ts | 9 +++++---- build/index-pieces.ts | 7 ++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/build/index-pages.ts b/build/index-pages.ts index 011acc8..09ce1bb 100644 --- a/build/index-pages.ts +++ b/build/index-pages.ts @@ -23,13 +23,14 @@ type FrontMatter = { const configRaw = fs.readFileSync(configPath, 'utf-8'); const config = yaml.load(configRaw) as any; -const rawNotFound = config?.pages?.notFound || 'obscured'; +const rawNotFound = String(config?.pages?.notFound || 'obscured'); const notFoundFile = rawNotFound.endsWith('.md') ? rawNotFound : `${rawNotFound}.md`; const rawExcludedPages = (config?.exclude?.pages || []).filter(Boolean); -const excludedPages = rawExcludedPages.map((page: string) => - page.endsWith('.md') ? page : `${page}.md` -); +const excludedPages = rawExcludedPages.map((page: string | number) => { + const pageStr = String(page); + return pageStr.endsWith('.md') ? pageStr : `${pageStr}.md`; +}); if (!excludedPages.includes(notFoundFile)) { excludedPages.push(notFoundFile); diff --git a/build/index-pieces.ts b/build/index-pieces.ts index 1c7c97c..7b8f7f8 100644 --- a/build/index-pieces.ts +++ b/build/index-pieces.ts @@ -34,9 +34,10 @@ const configRaw = fs.readFileSync(configPath, 'utf-8'); const config = yaml.load(configRaw) as any; const rawExcludedPieces = (config?.exclude?.pieces || []).filter(Boolean); -const excludedPieces = rawExcludedPieces.map((piece: string) => - piece.endsWith('.md') ? piece : `${piece}.md` -); +const excludedPieces = rawExcludedPieces.map((piece: string | number) => { + const pieceStr = String(piece); + return pieceStr.endsWith('.md') ? pieceStr : `${pieceStr}.md`; +}); const files = fs.readdirSync(piecesPath); if (files.length === 0) { From 9941b881942beed71b3b691cdfb7266ee402bb66 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Thu, 26 Feb 2026 02:26:08 +0530 Subject: [PATCH 03/13] docs: add GitHub Actions deployment guide with SSH key setup --- WRITING.md | 126 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 105 insertions(+), 21 deletions(-) diff --git a/WRITING.md b/WRITING.md index 7d42002..ab8a7f1 100644 --- a/WRITING.md +++ b/WRITING.md @@ -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: @@ -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 From 4c56e43779fce9cc598810c487e18c6f486aaaf5 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Thu, 26 Feb 2026 02:27:06 +0530 Subject: [PATCH 04/13] docs: update changelog and version badge for v1.3.0 --- CHANGELOG.md | 20 ++++++++++++++++++++ docsite/docusaurus.config.ts | 2 +- docsite/package.json | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c0aa58..937ff90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ 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.3.0] - 2026-02-26 + +### Added + +- Themed 502 error page generation with customizable text via `config.yaml`'s `redeployPage` section. +- Theme loader utility for build scripts. + +### Changed + +- Improved GitHub Actions deployment documentation in `WRITING.md` with SSH key setup guide. +- 502 page served from persistent host location (survives container restarts). + +### Fixed + +- Numeric values in `config.yaml` (e.g., `404`) now handled correctly. + +### Removed + +- `react-helmet` dependency (using React 19 native meta tags). + ## [1.2.9] - 2026-02-22 ### Changed diff --git a/docsite/docusaurus.config.ts b/docsite/docusaurus.config.ts index e5ede0a..8c14d6b 100644 --- a/docsite/docusaurus.config.ts +++ b/docsite/docusaurus.config.ts @@ -123,7 +123,7 @@ const config: Config = { { type: 'html', position: 'right', - value: 'v1.2.9', + value: 'v1.3.0', }, { href: 'https://demo.ode.dimwit.me/', diff --git a/docsite/package.json b/docsite/package.json index 9c273b1..b2c00ec 100644 --- a/docsite/package.json +++ b/docsite/package.json @@ -1,6 +1,6 @@ { "name": "docsite", - "version": "1.2.7", + "version": "1.3.0", "private": true, "scripts": { "docusaurus": "docusaurus", From dd3ad4b49fbc00a8e421fac86ab3ec2b4ae78fc5 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Thu, 26 Feb 2026 03:01:46 +0530 Subject: [PATCH 05/13] feat: add OG image generation with theme support --- build/generate-og-images.ts | 262 +++++++++++++++++++++ build/utils/theme-loader.ts | 18 ++ package-lock.json | 441 ++++++++++++++++++++++++++++++++++++ package.json | 2 + 4 files changed, 723 insertions(+) create mode 100644 build/generate-og-images.ts diff --git a/build/generate-og-images.ts b/build/generate-og-images.ts new file mode 100644 index 0000000..9ff8c64 --- /dev/null +++ b/build/generate-og-images.ts @@ -0,0 +1,262 @@ +import fs from 'fs'; +import path from 'path'; +import yaml from 'js-yaml'; +import satori from 'satori'; +import { Resvg } from '@resvg/resvg-js'; +import { loadTheme, applyOverrides, ThemeConfig, ThemeOverrides } from './utils/theme-loader'; + +interface Piece { + slug: string; + title: string; + date: string; + collections?: string[]; +} + +interface Page { + slug: string; + title: string; + date: string; +} + +interface Config { + site: { + title: string; + author: string; + tagline?: string; + url?: string; + }; + ui?: { + lowercase?: boolean; + theme?: { + preset?: string; + defaultMode?: 'light' | 'dark'; + overrides?: ThemeOverrides; + }; + }; +} + +const publicDir = path.join(process.cwd(), 'public'); +const generatedDir = path.join(publicDir, 'generated'); +const ogDir = path.join(generatedDir, 'og'); + +if (!fs.existsSync(ogDir)) { + fs.mkdirSync(ogDir, { 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.ui?.theme?.preset || 'journal'; +const baseTheme = loadTheme(themeName); + +if (!baseTheme) { + console.error(`Could not load theme: ${themeName}`); + process.exit(1); +} + +const theme = config.ui?.theme?.overrides + ? applyOverrides(baseTheme, config.ui.theme.overrides) + : baseTheme; + +const mode = config.ui?.theme?.defaultMode || 'light'; +const useLowercase = config.ui?.lowercase || false; +const colors = theme.colors[mode]; + +const applyCase = (str: string) => useLowercase ? str.toLowerCase() : str; + +async function loadFont(): Promise { + const fontUrl = theme.font.url; + + if (fontUrl.startsWith('http')) { + // Google Fonts URL - we need the raw font file (TTF, not WOFF2) + // Satori doesn't support WOFF2, so request TTF + const cssResponse = await fetch(fontUrl, { + headers: { + // Request TTF format by using an older user agent + 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36' + } + }); + const css = await cssResponse.text(); + const fontFileMatch = css.match(/src:\s*url\(([^)]+)\)\s*format\(['"]?truetype['"]?\)/); + + if (fontFileMatch) { + const fontFileUrl = fontFileMatch[1]; + const fontResponse = await fetch(fontFileUrl); + return fontResponse.arrayBuffer(); + } + + // Try any format if TTF not found + const anyFontMatch = css.match(/src:\s*url\(([^)]+\.ttf)\)/); + if (anyFontMatch) { + const fontResponse = await fetch(anyFontMatch[1]); + return fontResponse.arrayBuffer(); + } + + console.warn('Could not find TTF font in Google Fonts CSS, using fallback'); + return null; + } + + // Local font file + if (fs.existsSync(fontUrl) && fontUrl.match(/\.(ttf|otf)$/i)) { + const buffer = fs.readFileSync(fontUrl); + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + } + + // Can't use WOFF/WOFF2 with satori + console.warn(`Font format not supported by satori: ${fontUrl}`); + return null; +} + +async function generateOgImage( + title: string, + subtitle: string, + slug: string, + fontData: ArrayBuffer | null +): Promise { + const fontFamily = fontData ? theme.font.family : 'sans-serif'; + + const svg = await satori( + { + type: 'div', + props: { + style: { + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background, + padding: '60px', + }, + children: [ + { + type: 'div', + props: { + style: { + width: '80px', + height: '4px', + backgroundColor: colors.primary, + marginBottom: '40px', + }, + }, + }, + { + type: 'div', + props: { + style: { + fontSize: '52px', + fontFamily, + color: colors.text, + textAlign: 'center', + marginBottom: '20px', + maxWidth: '90%', + }, + children: applyCase(title), + }, + }, + { + type: 'div', + props: { + style: { + fontSize: '24px', + fontFamily, + color: colors.grey2, + textAlign: 'center', + }, + children: applyCase(subtitle), + }, + }, + { + type: 'div', + props: { + style: { + width: '80px', + height: '4px', + backgroundColor: colors.primary, + marginTop: '40px', + }, + }, + }, + ], + }, + }, + { + width: 1200, + height: 630, + fonts: fontData ? [ + { + name: theme.font.family, + data: fontData, + weight: 400, + style: 'normal', + }, + ] : [], + } + ); + + const resvg = new Resvg(svg, { + fitTo: { + mode: 'width', + value: 1200, + }, + }); + + const pngData = resvg.render(); + const pngBuffer = pngData.asPng(); + + fs.writeFileSync(path.join(ogDir, `${slug}.png`), pngBuffer); +} + +async function main() { + let fontData: ArrayBuffer | null = null; + + try { + fontData = await loadFont(); + if (fontData) { + console.log('Loaded custom font for OG images'); + } else { + console.log('Using fallback font for OG images'); + } + } catch (error) { + console.warn('Failed to load font, using fallback:', error); + } + + const siteTitle = config.site.title; + const siteAuthor = config.site.author; + const siteTagline = config.site.tagline || ''; + + // Generate OG image for homepage + await generateOgImage(siteTitle, siteTagline, 'index', fontData); + console.log('Generated og/index.png'); + + // Generate OG images for pieces + const piecesPath = path.join(generatedDir, 'index', 'pieces.json'); + if (fs.existsSync(piecesPath)) { + const pieces: Piece[] = JSON.parse(fs.readFileSync(piecesPath, 'utf-8')); + + for (const piece of pieces) { + const subtitle = piece.collections?.length + ? `${piece.collections[0]} · ${siteAuthor}` + : siteAuthor; + await generateOgImage(piece.title, subtitle, piece.slug, fontData); + } + console.log(`Generated ${pieces.length} piece OG images`); + } + + // Generate OG images for pages + const pagesPath = path.join(generatedDir, 'index', 'pages.json'); + if (fs.existsSync(pagesPath)) { + const pages: Page[] = JSON.parse(fs.readFileSync(pagesPath, 'utf-8')); + + for (const page of pages) { + await generateOgImage(page.title, siteTitle, `page-${page.slug}`, fontData); + } + console.log(`Generated ${pages.length} page OG images`); + } + + console.log('OG image generation complete'); +} + +main().catch(console.error); diff --git a/build/utils/theme-loader.ts b/build/utils/theme-loader.ts index d2fb854..cd51c75 100644 --- a/build/utils/theme-loader.ts +++ b/build/utils/theme-loader.ts @@ -67,3 +67,21 @@ export function getAvailableThemes(): string[] { .filter(file => file.endsWith('.js')) .map(file => file.replace('.js', '')); } + +export interface ThemeOverrides { + font?: Partial; + colors?: { + light?: Partial; + dark?: Partial; + }; +} + +export function applyOverrides(theme: ThemeConfig, overrides: ThemeOverrides): ThemeConfig { + return { + font: { ...theme.font, ...overrides.font }, + colors: { + light: { ...theme.colors.light, ...overrides.colors?.light }, + dark: { ...theme.colors.dark, ...overrides.colors?.dark } + } + }; +} diff --git a/package-lock.json b/package-lock.json index d115546..e4e20a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@resvg/resvg-js": "^2.6.2", "@types/js-yaml": "^4.0.9", "@types/node": "^24.10.1", "@types/react": "^19.2.5", @@ -29,6 +30,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "satori": "^0.19.2", "typescript": "^5.9.3", "vite": "^7.2.4" } @@ -1286,6 +1288,234 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.47", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", @@ -1579,6 +1809,23 @@ "win32" ] }, + "node_modules/@shuding/opentype.js": { + "version": "1.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz", + "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fflate": "^0.7.3", + "string.prototype.codepointat": "^0.2.1" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1827,6 +2074,16 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.8.30", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz", @@ -1914,6 +2171,16 @@ "node": ">=6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001756", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz", @@ -2085,6 +2352,52 @@ "node": ">= 8" } }, + "node_modules/css-background-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz", + "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-box-shadow": { + "version": "1.0.0-3", + "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz", + "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-gradient-parser": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.17.tgz", + "integrity": "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2170,6 +2483,16 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex-xs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz", + "integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2227,6 +2550,13 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2491,6 +2821,13 @@ } } }, + "node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2703,6 +3040,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hex-rgb": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz", + "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -2948,6 +3298,17 @@ "node": ">= 0.8.0" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3743,6 +4104,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "dev": true, + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3756,6 +4124,17 @@ "node": ">=6" } }, + "node_modules/parse-css-color": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz", + "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "^1.1.4", + "hex-rgb": "^4.1.0" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -3853,6 +4232,13 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4096,6 +4482,29 @@ "@parcel/watcher": "^2.4.1" } }, + "node_modules/satori": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/satori/-/satori-0.19.2.tgz", + "integrity": "sha512-71plFHWcq6WJBM5sf/n0eHOmTBiKLUB/G8du7SmLTTLHKEKrV3TPHGKcEVIoyjnbhnjvu9HhLyF9MATB/zzL7g==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@shuding/opentype.js": "1.4.0-beta.0", + "css-background-parser": "^0.1.0", + "css-box-shadow": "1.0.0-3", + "css-gradient-parser": "^0.0.17", + "css-to-react-native": "^3.0.0", + "emoji-regex-xs": "^2.0.1", + "escape-html": "^1.0.3", + "linebreak": "^1.1.0", + "parse-css-color": "^0.2.1", + "postcss-value-parser": "^4.2.0", + "yoga-layout": "^3.2.1" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4166,6 +4575,13 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -4224,6 +4640,13 @@ "node": ">=8" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4307,6 +4730,17 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -4605,6 +5039,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "dev": true, + "license": "MIT" + }, "node_modules/zod": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", diff --git a/package.json b/package.json index 785cc8d..97d53f1 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@resvg/resvg-js": "^2.6.2", "@types/js-yaml": "^4.0.9", "@types/node": "^24.10.1", "@types/react": "^19.2.5", @@ -40,6 +41,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "satori": "^0.19.2", "typescript": "^5.9.3", "vite": "^7.2.4" } From f3612ca145a35ba2d2a51c69c3e498d431a1d7dd Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Thu, 26 Feb 2026 03:02:45 +0530 Subject: [PATCH 06/13] feat: add meta pages generation for crawler support --- build/generate-meta-pages.ts | 212 +++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 build/generate-meta-pages.ts diff --git a/build/generate-meta-pages.ts b/build/generate-meta-pages.ts new file mode 100644 index 0000000..b0da417 --- /dev/null +++ b/build/generate-meta-pages.ts @@ -0,0 +1,212 @@ +import fs from 'fs'; +import path from 'path'; +import yaml from 'js-yaml'; +import { loadTheme, applyOverrides, ThemeConfig, ThemeOverrides } from './utils/theme-loader'; + +interface Piece { + slug: string; + title: string; + date: string; + collections?: string[]; +} + +interface Page { + slug: string; + title: string; + date: string; +} + +interface Config { + site: { + title: string; + author: string; + tagline?: string; + description?: string; + url?: string; + }; + ui?: { + lowercase?: boolean; + theme?: { + preset?: string; + defaultMode?: 'light' | 'dark'; + overrides?: ThemeOverrides; + }; + }; +} + +const publicDir = path.join(process.cwd(), 'public'); +const generatedDir = path.join(publicDir, 'generated'); +const metaDir = path.join(generatedDir, 'meta'); + +if (!fs.existsSync(metaDir)) { + fs.mkdirSync(metaDir, { 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.ui?.theme?.preset || 'journal'; +const baseTheme = loadTheme(themeName); + +if (!baseTheme) { + console.error(`Could not load theme: ${themeName}`); + process.exit(1); +} + +const theme = config.ui?.theme?.overrides + ? applyOverrides(baseTheme, config.ui.theme.overrides) + : baseTheme; + +const mode = config.ui?.theme?.defaultMode || 'light'; +const useLowercase = config.ui?.lowercase || false; +const colors = theme.colors[mode]; + +const applyCase = (str: string) => useLowercase ? str.toLowerCase() : str; + +const siteUrl = config.site.url || ''; +const siteTitle = config.site.title; +const siteAuthor = config.site.author; +const siteDescription = config.site.description || config.site.tagline || ''; + +function generateMetaPage( + slug: string, + title: string, + description: string, + ogImageSlug: string, + canonicalPath: string +): void { + const displayTitle = applyCase(title); + const displayDescription = applyCase(description); + const fullTitle = `${displayTitle} | ${applyCase(siteTitle)}`; + const canonicalUrl = `${siteUrl}${canonicalPath}`; + const ogImageUrl = `${siteUrl}/generated/og/${ogImageSlug}.png`; + + const html = ` + + + + + ${fullTitle} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

${displayTitle}

+

${displayDescription}

+

Continue to ${applyCase(siteTitle)}

+
+ +`; + + fs.writeFileSync(path.join(metaDir, `${slug}.html`), html); +} + +function main() { + // Generate meta page for homepage + generateMetaPage( + 'index', + siteTitle, + siteDescription, + 'index', + '/' + ); + console.log('Generated meta/index.html'); + + // Generate meta pages for pieces + const piecesPath = path.join(generatedDir, 'index', 'pieces.json'); + if (fs.existsSync(piecesPath)) { + const pieces: Piece[] = JSON.parse(fs.readFileSync(piecesPath, 'utf-8')); + + for (const piece of pieces) { + const description = piece.collections?.length + ? `${piece.collections.join(', ')} by ${siteAuthor}` + : `A piece by ${siteAuthor}`; + + generateMetaPage( + piece.slug, + piece.title, + description, + piece.slug, + `/${piece.slug}` + ); + } + console.log(`Generated ${pieces.length} piece meta pages`); + } + + // Generate meta pages for pages + const pagesPath = path.join(generatedDir, 'index', 'pages.json'); + if (fs.existsSync(pagesPath)) { + const pages: Page[] = JSON.parse(fs.readFileSync(pagesPath, 'utf-8')); + + for (const page of pages) { + generateMetaPage( + `page-${page.slug}`, + page.title, + `${page.title} - ${siteTitle}`, + `page-${page.slug}`, + `/${page.slug}` + ); + } + console.log(`Generated ${pages.length} page meta pages`); + } + + console.log('Meta page generation complete'); +} + +main(); From 69f4222bab6f9d131600efb77b30f0304926c4dc Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Thu, 26 Feb 2026 03:04:26 +0530 Subject: [PATCH 07/13] feat: add bot detection to nginx for social card previews --- nginx-template | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/nginx-template b/nginx-template index 9cf4506..4a161c8 100644 --- a/nginx-template +++ b/nginx-template @@ -1,3 +1,22 @@ +# Bot detection for serving pre-rendered meta pages +# This map checks if the user agent is a known crawler/bot +map $http_user_agent $is_bot { + default 0; + ~*bot 1; + ~*crawl 1; + ~*spider 1; + ~*slurp 1; + ~*facebookexternalhit 1; + ~*Facebot 1; + ~*Twitterbot 1; + ~*LinkedInBot 1; + ~*Pinterest 1; + ~*Telegram 1; + ~*WhatsApp 1; + ~*Discord 1; + ~*Slack 1; +} + server { listen 80; server_name your-domain.com; @@ -70,8 +89,27 @@ server { internal; } - # Everything else + # Serve pre-rendered meta pages to bots for social card previews + # Homepage for bots + location = / { + if ($is_bot) { + rewrite ^ /generated/meta/index.html break; + } + proxy_pass http://localhost:PORT; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Everything else (with bot detection for piece/page meta pages) location / { + # For bots: try to serve pre-rendered meta page + # The meta pages are at /generated/meta/{slug}.html + if ($is_bot) { + rewrite ^/([^/]+)/?$ /generated/meta/$1.html break; + } + proxy_pass http://localhost:PORT; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; From ec1894eb7b6e8d85a4cb16a089b82a89f8e6588d Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Thu, 26 Feb 2026 03:05:41 +0530 Subject: [PATCH 08/13] feat: add bot detection to Vercel config for social card previews --- vercel.json | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/vercel.json b/vercel.json index f224547..d3719e0 100644 --- a/vercel.json +++ b/vercel.json @@ -25,6 +25,28 @@ "source": "/robots.txt", "destination": "/robots.txt" }, + { + "source": "/", + "has": [ + { + "type": "header", + "key": "user-agent", + "value": ".*(bot|crawl|spider|slurp|facebookexternalhit|facebot|twitterbot|linkedinbot|pinterest|telegram|whatsapp|discord|slack).*" + } + ], + "destination": "/generated/meta/index.html" + }, + { + "source": "/:slug", + "has": [ + { + "type": "header", + "key": "user-agent", + "value": ".*(bot|crawl|spider|slurp|facebookexternalhit|facebot|twitterbot|linkedinbot|pinterest|telegram|whatsapp|discord|slack).*" + } + ], + "destination": "/generated/meta/:slug.html" + }, { "source": "/(.*)", "destination": "/index.html" @@ -32,11 +54,11 @@ ], "headers": [ { - "source": "/generated/meta-images/(.*)", + "source": "/generated/og/(.*)", "headers": [ { "key": "Content-Type", - "value": "image/svg+xml" + "value": "image/png" }, { "key": "Cache-Control", @@ -44,6 +66,19 @@ } ] }, + { + "source": "/generated/meta/(.*)", + "headers": [ + { + "key": "Content-Type", + "value": "text/html; charset=utf-8" + }, + { + "key": "Cache-Control", + "value": "public, max-age=3600" + } + ] + }, { "source": "/generated/feed.xml", "headers": [ From df577218c7f28397190e565b790e3d0c65fb5e7d Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Thu, 26 Feb 2026 03:15:15 +0530 Subject: [PATCH 09/13] chore: standardize logging format to [domain]: message --- build/calculate-stats.ts | 4 ++-- build/ensure-defaults.ts | 18 +++++++-------- build/generate-502-page.ts | 4 ++-- build/generate-body-of-work.ts | 6 ++--- build/generate-meta-pages.ts | 10 ++++----- build/generate-og-images.ts | 24 ++++++++++---------- build/generate-rss.ts | 4 ++-- build/generate-sitemap.ts | 4 ++-- build/index-pages.ts | 16 ++++++------- build/index-pieces.ts | 26 +++++++++++----------- build/paginate-pieces.ts | 6 ++--- build/utils/theme-loader.ts | 6 ++--- package.json | 5 +++-- src/components/BookViewer/BookViewer.jsx | 4 ++-- src/components/LampToggle/LampToggle.jsx | 2 +- src/components/Navigation/Navigation.jsx | 2 +- src/components/Volumes/Volumes.jsx | 2 +- src/components/WordsWasted/WordsWasted.jsx | 2 +- src/utils/loadConfig.js | 2 +- src/utils/loadTheme.js | 2 +- src/utils/resolveContentPath.js | 4 ++-- 21 files changed, 76 insertions(+), 77 deletions(-) diff --git a/build/calculate-stats.ts b/build/calculate-stats.ts index e983174..38c0bed 100644 --- a/build/calculate-stats.ts +++ b/build/calculate-stats.ts @@ -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); } diff --git a/build/ensure-defaults.ts b/build/ensure-defaults.ts index df3b4ba..7dc8ac7 100644 --- a/build/ensure-defaults.ts +++ b/build/ensure-defaults.ts @@ -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); } @@ -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); } @@ -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); @@ -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); @@ -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'); diff --git a/build/generate-502-page.ts b/build/generate-502-page.ts index 8952258..1a3f8fe 100644 --- a/build/generate-502-page.ts +++ b/build/generate-502-page.ts @@ -42,7 +42,7 @@ const themeName = config.theme || 'journal'; const theme = loadTheme(themeName); if (!theme) { - console.error(`Could not load theme: ${themeName}`); + console.error(`[redeploy]: could not load theme: ${themeName}`); process.exit(1); } @@ -88,4 +88,4 @@ function generate502Page(theme: ThemeConfig): string { const html = generate502Page(theme); fs.writeFileSync(path.join(generatedDir, '502.html'), html); -console.log('Generated 502.html'); +console.log('[redeploy]: generated 502.html'); diff --git a/build/generate-body-of-work.ts b/build/generate-body-of-work.ts index 5f08582..cd8cd85 100644 --- a/build/generate-body-of-work.ts +++ b/build/generate-body-of-work.ts @@ -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); @@ -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`); diff --git a/build/generate-meta-pages.ts b/build/generate-meta-pages.ts index b0da417..25868be 100644 --- a/build/generate-meta-pages.ts +++ b/build/generate-meta-pages.ts @@ -50,7 +50,7 @@ const themeName = config.ui?.theme?.preset || 'journal'; const baseTheme = loadTheme(themeName); if (!baseTheme) { - console.error(`Could not load theme: ${themeName}`); + console.error(`[meta]: could not load theme: ${themeName}`); process.exit(1); } @@ -166,7 +166,7 @@ function main() { 'index', '/' ); - console.log('Generated meta/index.html'); + console.log('[meta]: generated meta/index.html'); // Generate meta pages for pieces const piecesPath = path.join(generatedDir, 'index', 'pieces.json'); @@ -186,7 +186,7 @@ function main() { `/${piece.slug}` ); } - console.log(`Generated ${pieces.length} piece meta pages`); + console.log(`[meta]: generated ${pieces.length} piece meta pages`); } // Generate meta pages for pages @@ -203,10 +203,10 @@ function main() { `/${page.slug}` ); } - console.log(`Generated ${pages.length} page meta pages`); + console.log(`[meta]: generated ${pages.length} page meta pages`); } - console.log('Meta page generation complete'); + console.log('[meta]: generation complete'); } main(); diff --git a/build/generate-og-images.ts b/build/generate-og-images.ts index 9ff8c64..d96db5e 100644 --- a/build/generate-og-images.ts +++ b/build/generate-og-images.ts @@ -51,7 +51,7 @@ const themeName = config.ui?.theme?.preset || 'journal'; const baseTheme = loadTheme(themeName); if (!baseTheme) { - console.error(`Could not load theme: ${themeName}`); + console.error(`[og-images]: could not load theme: ${themeName}`); process.exit(1); } @@ -93,7 +93,7 @@ async function loadFont(): Promise { return fontResponse.arrayBuffer(); } - console.warn('Could not find TTF font in Google Fonts CSS, using fallback'); + console.warn('[og-images]: could not find TTF font in Google Fonts CSS, using fallback'); return null; } @@ -104,7 +104,7 @@ async function loadFont(): Promise { } // Can't use WOFF/WOFF2 with satori - console.warn(`Font format not supported by satori: ${fontUrl}`); + console.warn(`[og-images]: font format not supported by satori: ${fontUrl}`); return null; } @@ -117,7 +117,7 @@ async function generateOgImage( const fontFamily = fontData ? theme.font.family : 'sans-serif'; const svg = await satori( - { + ({ type: 'div', props: { style: { @@ -181,7 +181,7 @@ async function generateOgImage( }, ], }, - }, + }) as any, { width: 1200, height: 630, @@ -215,12 +215,12 @@ async function main() { try { fontData = await loadFont(); if (fontData) { - console.log('Loaded custom font for OG images'); + console.log('[og-images]: loaded custom font'); } else { - console.log('Using fallback font for OG images'); + console.log('[og-images]: using fallback font'); } } catch (error) { - console.warn('Failed to load font, using fallback:', error); + console.warn('[og-images]: failed to load font, using fallback:', error); } const siteTitle = config.site.title; @@ -229,7 +229,7 @@ async function main() { // Generate OG image for homepage await generateOgImage(siteTitle, siteTagline, 'index', fontData); - console.log('Generated og/index.png'); + console.log('[og-images]: generated og/index.png'); // Generate OG images for pieces const piecesPath = path.join(generatedDir, 'index', 'pieces.json'); @@ -242,7 +242,7 @@ async function main() { : siteAuthor; await generateOgImage(piece.title, subtitle, piece.slug, fontData); } - console.log(`Generated ${pieces.length} piece OG images`); + console.log(`[og-images]: generated ${pieces.length} piece images`); } // Generate OG images for pages @@ -253,10 +253,10 @@ async function main() { for (const page of pages) { await generateOgImage(page.title, siteTitle, `page-${page.slug}`, fontData); } - console.log(`Generated ${pages.length} page OG images`); + console.log(`[og-images]: generated ${pages.length} page images`); } - console.log('OG image generation complete'); + console.log('[og-images]: generation complete'); } main().catch(console.error); diff --git a/build/generate-rss.ts b/build/generate-rss.ts index b683e3d..8429fa5 100644 --- a/build/generate-rss.ts +++ b/build/generate-rss.ts @@ -141,9 +141,9 @@ interface FrontMatter { rssLines.push(''); fs.writeFileSync(rssPath, rssLines.join('\n')); - console.log(`RSS feed generated successfully with ${sortedPieces.length} pieces`); + console.log(`[rss]: generated feed with ${sortedPieces.length} pieces`); } catch (error) { - console.error('Error generating RSS feed:', error); + console.error('[rss]: error generating feed:', error); process.exit(1); } })(); diff --git a/build/generate-sitemap.ts b/build/generate-sitemap.ts index c4136c3..ebb19cb 100644 --- a/build/generate-sitemap.ts +++ b/build/generate-sitemap.ts @@ -84,9 +84,9 @@ interface Collection { sitemapLines.push(''); fs.writeFileSync(sitemapPath, sitemapLines.join('\n')); - console.log(`Sitemap generated successfully with ${1 + pages.length + collections.length + pieces.length} URLs`); + console.log(`[sitemap]: generated with ${1 + pages.length + collections.length + pieces.length} URLs`); } catch (error) { - console.error('Error generating sitemap:', error); + console.error('[sitemap]: error generating:', error); process.exit(1); } })(); diff --git a/build/index-pages.ts b/build/index-pages.ts index 09ce1bb..6ba1ee4 100644 --- a/build/index-pages.ts +++ b/build/index-pages.ts @@ -38,7 +38,7 @@ if (!excludedPages.includes(notFoundFile)) { const files = fs.readdirSync(pagesPath); if (files.length === 0) { - console.log('No files found in the pages directory.'); + console.log('[pages]: no files found in pages directory'); process.exit(0); } @@ -51,33 +51,33 @@ files.forEach(file => { } if (excludedPages.includes(file)) { - console.log(`Excluding: ${file} (listed in config.yaml)`); + console.log(`[pages]: excluding ${file} (listed in config.yaml)`); return; } const filePath = path.join(pagesPath, file); const stats = fs.statSync(filePath); if (stats.isFile()) { - console.log(`Indexing: ${file}, Size: ${stats.size} bytes`); + console.log(`[pages]: indexing ${file} (${stats.size} bytes)`); const raw = fs.readFileSync(filePath, 'utf-8'); const parsed = fm(raw); const { title, date, slug } = parsed.attributes; if (!title || typeof title !== 'string' || title.trim() === '') { - console.warn(`Skipping ${file}: Missing or invalid title`); + console.warn(`[pages]: skipping ${file}: missing or invalid title`); errors.push(file); return; } if (!date) { - console.warn(`Skipping ${file}: Missing date`); + console.warn(`[pages]: skipping ${file}: missing date`); errors.push(file); return; } if (!slug || typeof slug !== 'string' || slug.trim() === '') { - console.warn(`Skipping ${file}: Missing or invalid slug`); + console.warn(`[pages]: skipping ${file}: missing or invalid slug`); errors.push(file); return; } @@ -109,9 +109,9 @@ if (pagesOrder.length > 0) { } fs.writeFileSync(indexPath, JSON.stringify(index, null, 2)); -console.log(`pages.json created successfully with ${index.length} entries.`); +console.log(`[pages]: created pages.json with ${index.length} entries`); if (errors.length > 0) { fs.writeFileSync(errorsPath, JSON.stringify(errors, null, 2)); - console.log(`Errors logged for ${errors.length} files.`); + console.log(`[pages]: ${errors.length} files had errors`); } diff --git a/build/index-pieces.ts b/build/index-pieces.ts index 7b8f7f8..5b476eb 100644 --- a/build/index-pieces.ts +++ b/build/index-pieces.ts @@ -41,7 +41,7 @@ const excludedPieces = rawExcludedPieces.map((piece: string | number) => { const files = fs.readdirSync(piecesPath); if (files.length === 0) { - console.log('No files found in the pieces directory.'); + console.log('[pieces]: no files found in pieces directory'); process.exit(0); } @@ -54,34 +54,34 @@ files.forEach(file => { } if (excludedPieces.includes(file)) { - console.log(`Excluding: ${file} (listed in config.yaml)`); + console.log(`[pieces]: excluding ${file} (listed in config.yaml)`); return; } const filePath = path.join(piecesPath, file); const stats = fs.statSync(filePath); if (stats.isFile()) { - console.log(`Indexing: ${file}, Size: ${stats.size} bytes`); + console.log(`[pieces]: indexing ${file} (${stats.size} bytes)`); const raw = fs.readFileSync(filePath, 'utf-8'); const parsed = fm(raw); const { title, date, categories, slug } = parsed.attributes; if (!title || typeof title !== 'string' || title.trim() === '') { - console.warn(`Skipping ${file}: Missing or invalid title`); + console.warn(`[pieces]: skipping ${file}: missing or invalid title`); errors.push(file); return; } if (!date) { - console.warn(`Skipping ${file}: Missing date`); + console.warn(`[pieces]: skipping ${file}: missing date`); errors.push(file); return; } const dateObj = date instanceof Date ? date : new Date(date as string); if (isNaN(dateObj.getTime())) { - console.warn(`Skipping ${file}: Invalid date`); + console.warn(`[pieces]: skipping ${file}: invalid date`); errors.push(file); return; } @@ -89,13 +89,13 @@ files.forEach(file => { const dateString = dateObj.toISOString().split('T')[0]; if (!categories || !Array.isArray(categories) || categories.length === 0) { - console.warn(`Skipping ${file}: Missing or invalid categories (must be non-empty array)`); + console.warn(`[pieces]: skipping ${file}: missing or invalid categories`); errors.push(file); return; } if (!slug || typeof slug !== 'string' || slug.trim() === '') { - console.warn(`Skipping ${file}: Missing or invalid slug`); + console.warn(`[pieces]: skipping ${file}: missing or invalid slug`); errors.push(file); return; } @@ -118,7 +118,7 @@ if (!fs.existsSync(piecesIndexDir)) { } fs.writeFileSync(piecesIndexPath, JSON.stringify(index, null, 2)); -console.log(`pieces.json created successfully with ${index.length} entries.`); +console.log(`[pieces]: created pieces.json with ${index.length} entries`); const pieces: Piece[] = index; const collectionsMap: { [key: string]: Collection } = {}; @@ -151,7 +151,7 @@ collections.forEach(collection => { throw new Error(`Invalid order "${order}" for collection "${collection.name}". Must be "ascending" or "descending".`); } - console.log(`Sorting collection "${collection.name}" ${order} order, using ${readerOrderConfig[collection.name] ? 'custom' : 'default'} setting.`); + console.log(`[pieces]: sorting collection "${collection.name}" ${order} (${readerOrderConfig[collection.name] ? 'custom' : 'default'})`); collection.pieces.sort((a, b) => { const pieceA = pieces.find(p => p.slug === a); const pieceB = pieces.find(p => p.slug === b); @@ -163,11 +163,11 @@ collections.forEach(collection => { }); fs.writeFileSync(collectionsPath, JSON.stringify(collections, null, 2)); -console.log(`collections.json created successfully with ${collections.length} categories and ${pieces.length} total pieces.`); +console.log(`[pieces]: created collections.json with ${collections.length} categories`); fs.writeFileSync(errorsPath, JSON.stringify(errors, null, 2)); if (errors.length > 0) { - console.log(`${errors.length} file(s) skipped due to missing front matter. See errors.json for details.`); + console.log(`[pieces]: ${errors.length} files skipped (see errors.json)`); } else { - console.log('All files processed successfully.'); + console.log('[pieces]: all files processed successfully'); } \ No newline at end of file diff --git a/build/paginate-pieces.ts b/build/paginate-pieces.ts index edd653c..06dd26a 100644 --- a/build/paginate-pieces.ts +++ b/build/paginate-pieces.ts @@ -76,7 +76,7 @@ piecesIndex.forEach((piece: any) => { const filePath = path.join(piecesPath, `${slug}.md`); if (!fs.existsSync(filePath)) { - console.warn(`File not found for slug: ${slug}`); + console.warn(`[pagination]: file not found for slug: ${slug}`); return; } @@ -99,8 +99,8 @@ piecesIndex.forEach((piece: any) => { pages }; - console.log(`Paginated ${slug}: ${totalPages} page(s)`); + console.log(`[pagination]: ${slug}: ${totalPages} page(s)`); }); fs.writeFileSync(pagesIndexPath, JSON.stringify(pageIndex, null, 2)); -console.log(`\nCreated pieces-pages.json with ${Object.keys(pageIndex).length} pieces.`); +console.log(`[pagination]: created pieces-pages.json with ${Object.keys(pageIndex).length} pieces`); diff --git a/build/utils/theme-loader.ts b/build/utils/theme-loader.ts index cd51c75..357b39e 100644 --- a/build/utils/theme-loader.ts +++ b/build/utils/theme-loader.ts @@ -35,7 +35,7 @@ export function loadTheme(themeName: string): ThemeConfig | null { const themePath = path.join(themesDir, `${themeName}.js`); if (!fs.existsSync(themePath)) { - console.error(`Theme file not found: ${themePath}`); + console.error(`[theme]: file not found: ${themePath}`); return null; } @@ -44,7 +44,7 @@ export function loadTheme(themeName: string): ThemeConfig | null { const defaultExportMatch = themeContent.match(/export\s+default\s+({[\s\S]*});?\s*$/); if (!defaultExportMatch) { - console.error(`Could not parse theme export in: ${themePath}`); + console.error(`[theme]: could not parse export in: ${themePath}`); return null; } @@ -53,7 +53,7 @@ export function loadTheme(themeName: string): ThemeConfig | null { return theme; } catch (error) { - console.error(`Error loading theme ${themeName}:`, error); + console.error(`[theme]: error loading ${themeName}:`, error); return null; } } diff --git a/package.json b/package.json index 97d53f1..8c46b90 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "npm run build:index && vite build", - "build:index": "vite-node build/ensure-defaults.ts && vite-node build/generate-502-page.ts && vite-node build/index-pieces.ts && vite-node build/index-pages.ts && vite-node build/paginate-pieces.ts && vite-node build/calculate-stats.ts && vite-node build/generate-rss.ts && vite-node build/generate-sitemap.ts && vite-node build/generate-body-of-work.ts", + "build:index": "vite-node build/ensure-defaults.ts && vite-node build/generate-502-page.ts && vite-node build/index-pieces.ts && vite-node build/index-pages.ts && vite-node build/paginate-pieces.ts && vite-node build/calculate-stats.ts && vite-node build/generate-rss.ts && vite-node build/generate-sitemap.ts && vite-node build/generate-body-of-work.ts && vite-node build/generate-og-images.ts && vite-node build/generate-meta-pages.ts", "build:pieces": "vite-node build/index-pieces.ts", "build:pages": "vite-node build/index-pages.ts", "build:paginate": "vite-node build/paginate-pieces.ts", @@ -14,7 +14,8 @@ "build:rss": "vite-node build/generate-rss.ts", "build:sitemap": "vite-node build/generate-sitemap.ts", "build:502": "vite-node build/generate-502-page.ts", - "build:meta": "vite-node build/generate-meta-images.ts", + "build:og": "vite-node build/generate-og-images.ts", + "build:meta": "vite-node build/generate-meta-pages.ts", "lint": "eslint .", "preview": "vite preview" }, diff --git a/src/components/BookViewer/BookViewer.jsx b/src/components/BookViewer/BookViewer.jsx index d1cb7d9..3a005ba 100644 --- a/src/components/BookViewer/BookViewer.jsx +++ b/src/components/BookViewer/BookViewer.jsx @@ -31,7 +31,7 @@ function BookViewer() { ]); if (!pagesRes.ok || !collectionsRes.ok || !piecesRes.ok) { - console.error('Failed to load index files'); + console.error('[reader]: failed to load index files'); return; } @@ -44,7 +44,7 @@ function BookViewer() { setPiecesIndex(piecesData); setConfig(configData); } catch (error) { - console.error('Error loading data:', error); + console.error('[reader]: error loading data:', error); } finally { setLoading(false); } diff --git a/src/components/LampToggle/LampToggle.jsx b/src/components/LampToggle/LampToggle.jsx index a6aa92e..4162072 100644 --- a/src/components/LampToggle/LampToggle.jsx +++ b/src/components/LampToggle/LampToggle.jsx @@ -19,7 +19,7 @@ function LampToggle() { const audioOffRef = useRef(null); useEffect(() => { - loadConfig().then(setConfig).catch(console.error); + loadConfig().then(setConfig).catch(e => console.error('[lamp]:', e)); }, []); useEffect(() => { diff --git a/src/components/Navigation/Navigation.jsx b/src/components/Navigation/Navigation.jsx index 74be768..ad0d4f2 100644 --- a/src/components/Navigation/Navigation.jsx +++ b/src/components/Navigation/Navigation.jsx @@ -23,7 +23,7 @@ function Navigation() { setPieces(piecesData); setConfig(configData); }) - .catch(error => console.error('Error loading data:', error)); + .catch(error => console.error('[nav]: error loading data:', error)); }, []); const handleRandomPiece = () => { diff --git a/src/components/Volumes/Volumes.jsx b/src/components/Volumes/Volumes.jsx index cd4cd8b..d752fca 100644 --- a/src/components/Volumes/Volumes.jsx +++ b/src/components/Volumes/Volumes.jsx @@ -16,7 +16,7 @@ function Volumes() { setCollections(collectionsData); setConfig(configData); }) - .catch(error => console.error('Error loading data:', error)); + .catch(error => console.error('[volumes]: error loading data:', error)); }, []); if (collections.length === 0) { diff --git a/src/components/WordsWasted/WordsWasted.jsx b/src/components/WordsWasted/WordsWasted.jsx index 248146c..11567af 100644 --- a/src/components/WordsWasted/WordsWasted.jsx +++ b/src/components/WordsWasted/WordsWasted.jsx @@ -15,7 +15,7 @@ function WordsWasted() { setConfig(configData); setStats(statsData); }) - .catch(console.error); + .catch(e => console.error('[stats]:', e)); }, []); if (!config || !stats) { diff --git a/src/utils/loadConfig.js b/src/utils/loadConfig.js index 065b3f4..79ce495 100644 --- a/src/utils/loadConfig.js +++ b/src/utils/loadConfig.js @@ -15,7 +15,7 @@ export async function loadConfig() { return config; } catch (error) { - console.error('Error loading configuration:', error); + console.error('[config]: error loading configuration:', error); throw error; } } diff --git a/src/utils/loadTheme.js b/src/utils/loadTheme.js index 618a077..cbcabf4 100644 --- a/src/utils/loadTheme.js +++ b/src/utils/loadTheme.js @@ -14,7 +14,7 @@ export function loadTheme(config) { const defaultMode = config?.ui?.theme?.defaultMode || null; if (!themes.has(themeName)) { - console.warn(`Theme "${themeName}" not found. Falling back to "${defaultTheme}".`); + console.warn(`[theme]: "${themeName}" not found, falling back to "${defaultTheme}"`); } const baseTheme = themes.get(themeName) || themes.get(defaultTheme); diff --git a/src/utils/resolveContentPath.js b/src/utils/resolveContentPath.js index 6fd86c5..9153441 100644 --- a/src/utils/resolveContentPath.js +++ b/src/utils/resolveContentPath.js @@ -22,7 +22,7 @@ export async function resolveContentPath({ pathname }) { const pages = await pagesRes.json(); isPage = pages.some(page => page.slug === slug); } catch (error) { - console.log('Error fetching pages index:', error); + console.error('[router]: error fetching pages index:', error); isPage = false; } if (!isPage) { @@ -31,7 +31,7 @@ export async function resolveContentPath({ pathname }) { const pieces = await piecesRes.json(); isPiece = pieces.some(piece => piece.slug === slug); } catch (error) { - console.log('Error fetching pieces index:', error); + console.error('[router]: error fetching pieces index:', error); isPiece = false; } } From 54de947dd96246b0509557842e8807982e8ceba9 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Thu, 26 Feb 2026 04:14:53 +0530 Subject: [PATCH 10/13] feat: enhance nginx configuration with bot detection and meta page handling as well as improve documentation --- README.md | 4 +- build/generate-meta-pages.ts | 9 +-- build/generate-og-images.ts | 38 ++++++------ nginx/README.md | 86 ++++++++++++++++++++++++++++ nginx/bot-map.conf | 21 +++++++ nginx-template => nginx/site.conf | 55 +++++++----------- public/content/pages/body-of-work.md | 2 +- vercel.json | 27 +++++++++ 8 files changed, 182 insertions(+), 60 deletions(-) create mode 100644 nginx/README.md create mode 100644 nginx/bot-map.conf rename nginx-template => nginx/site.conf (75%) diff --git a/README.md b/README.md index e77958a..7bb149d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build/generate-meta-pages.ts b/build/generate-meta-pages.ts index 25868be..a87cd09 100644 --- a/build/generate-meta-pages.ts +++ b/build/generate-meta-pages.ts @@ -158,7 +158,6 @@ function generateMetaPage( } function main() { - // Generate meta page for homepage generateMetaPage( 'index', siteTitle, @@ -168,7 +167,6 @@ function main() { ); console.log('[meta]: generated meta/index.html'); - // Generate meta pages for pieces const piecesPath = path.join(generatedDir, 'index', 'pieces.json'); if (fs.existsSync(piecesPath)) { const pieces: Piece[] = JSON.parse(fs.readFileSync(piecesPath, 'utf-8')); @@ -188,18 +186,17 @@ function main() { } console.log(`[meta]: generated ${pieces.length} piece meta pages`); } - - // Generate meta pages for pages + const pagesPath = path.join(generatedDir, 'index', 'pages.json'); if (fs.existsSync(pagesPath)) { const pages: Page[] = JSON.parse(fs.readFileSync(pagesPath, 'utf-8')); for (const page of pages) { generateMetaPage( - `page-${page.slug}`, + page.slug, page.title, `${page.title} - ${siteTitle}`, - `page-${page.slug}`, + page.slug, `/${page.slug}` ); } diff --git a/build/generate-og-images.ts b/build/generate-og-images.ts index d96db5e..084f337 100644 --- a/build/generate-og-images.ts +++ b/build/generate-og-images.ts @@ -69,11 +69,8 @@ async function loadFont(): Promise { const fontUrl = theme.font.url; if (fontUrl.startsWith('http')) { - // Google Fonts URL - we need the raw font file (TTF, not WOFF2) - // Satori doesn't support WOFF2, so request TTF const cssResponse = await fetch(fontUrl, { headers: { - // Request TTF format by using an older user agent 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36' } }); @@ -86,7 +83,6 @@ async function loadFont(): Promise { return fontResponse.arrayBuffer(); } - // Try any format if TTF not found const anyFontMatch = css.match(/src:\s*url\(([^)]+\.ttf)\)/); if (anyFontMatch) { const fontResponse = await fetch(anyFontMatch[1]); @@ -97,17 +93,18 @@ async function loadFont(): Promise { return null; } - // Local font file if (fs.existsSync(fontUrl) && fontUrl.match(/\.(ttf|otf)$/i)) { const buffer = fs.readFileSync(fontUrl); return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); } - // Can't use WOFF/WOFF2 with satori console.warn(`[og-images]: font format not supported by satori: ${fontUrl}`); return null; } +const BATCH_SIZE = 5; +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + async function generateOgImage( title: string, subtitle: string, @@ -227,31 +224,40 @@ async function main() { const siteAuthor = config.site.author; const siteTagline = config.site.tagline || ''; - // Generate OG image for homepage await generateOgImage(siteTitle, siteTagline, 'index', fontData); console.log('[og-images]: generated og/index.png'); - // Generate OG images for pieces const piecesPath = path.join(generatedDir, 'index', 'pieces.json'); if (fs.existsSync(piecesPath)) { const pieces: Piece[] = JSON.parse(fs.readFileSync(piecesPath, 'utf-8')); - for (const piece of pieces) { - const subtitle = piece.collections?.length - ? `${piece.collections[0]} · ${siteAuthor}` - : siteAuthor; - await generateOgImage(piece.title, subtitle, piece.slug, fontData); + for (let i = 0; i < pieces.length; i += BATCH_SIZE) { + const batch = pieces.slice(i, i + BATCH_SIZE); + for (const piece of batch) { + const subtitle = piece.collections?.length + ? `${piece.collections[0]} · ${siteAuthor}` + : siteAuthor; + await generateOgImage(piece.title, subtitle, piece.slug, fontData); + } + if (i + BATCH_SIZE < pieces.length) { + await delay(50); + } } console.log(`[og-images]: generated ${pieces.length} piece images`); } - // Generate OG images for pages const pagesPath = path.join(generatedDir, 'index', 'pages.json'); if (fs.existsSync(pagesPath)) { const pages: Page[] = JSON.parse(fs.readFileSync(pagesPath, 'utf-8')); - for (const page of pages) { - await generateOgImage(page.title, siteTitle, `page-${page.slug}`, fontData); + for (let i = 0; i < pages.length; i += BATCH_SIZE) { + const batch = pages.slice(i, i + BATCH_SIZE); + for (const page of batch) { + await generateOgImage(page.title, siteTitle, page.slug, fontData); + } + if (i + BATCH_SIZE < pages.length) { + await delay(50); + } } console.log(`[og-images]: generated ${pages.length} page images`); } diff --git a/nginx/README.md b/nginx/README.md new file mode 100644 index 0000000..ec5c046 --- /dev/null +++ b/nginx/README.md @@ -0,0 +1,86 @@ +# nginx Configuration + +This directory contains nginx configuration templates for self-hosting Ode. + +## Files + +- **bot-map.conf**: Bot detection maps (must be placed at http level) +- **site.conf**: Server block template + +## Setup + +### 1. Install the bot detection map + +Copy `bot-map.conf` to your nginx conf.d directory: + +```bash +sudo cp bot-map.conf /etc/nginx/conf.d/bot-map.conf +``` + +### 2. Configure the site + +Copy `site.conf` and edit it: + +```bash +sudo cp site.conf /etc/nginx/sites-available/your-domain.com +``` + +Replace in the file: +- `your-domain.com` with your actual domain +- `PORT` with the port your Ode container is running on (e.g., `8080`) +- `YOUR_PROJECT_NAME` with your project name (for 502 error page) + +### 3. Enable and reload + +```bash +sudo ln -s /etc/nginx/sites-available/your-domain.com /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +## What this does + +- Proxies requests to your Ode container +- Handles SPA routing (404 → index.html) +- Serves RSS feed at `/feed.xml` and `/feed/` +- Serves sitemap at `/sitemap.xml` and `/sitemap/` +- Serves pre-rendered meta pages to bots for social card previews (WhatsApp, LinkedIn, Twitter, etc.) + +## Serving generated files directly (optional) + +For better performance with bot detection, you can serve the generated files directly from disk instead of proxying through the container. This is useful if you want nginx to serve the meta pages without going through the app. + +### Option A: Host from /var/www (recommended) + +Create a symlink or copy the generated directory: + +```bash +sudo mkdir -p /var/www/your-project +sudo ln -s /path/to/your/public/generated /var/www/your-project/generated +``` + +Then update the `/generated/` location in your site config: + +```nginx +location /generated/ { + alias /var/www/your-project/generated/; +} +``` + +### Option B: Fix permissions on home directory + +If your app runs from a home directory (e.g., `/home/user/...`), nginx needs read access: + +```bash +chmod +x /home/user +chmod -R +r /home/user/your-project/public/generated +``` + +Then use an alias: + +```nginx +location /generated/ { + alias /home/user/your-project/public/generated/; +} +``` + diff --git a/nginx/bot-map.conf b/nginx/bot-map.conf new file mode 100644 index 0000000..8d4f643 --- /dev/null +++ b/nginx/bot-map.conf @@ -0,0 +1,21 @@ +map $http_user_agent $is_bot { + default 0; + ~*bot 1; + ~*crawl 1; + ~*spider 1; + ~*slurp 1; + ~*facebookexternalhit 1; + ~*Facebot 1; + ~*Twitterbot 1; + ~*LinkedInBot 1; + ~*Pinterest 1; + ~*Telegram 1; + ~*WhatsApp 1; + ~*Discord 1; + ~*Slack 1; +} + +map $is_bot$arg_piece $reader_meta_slug { + default ""; + ~^1.+$ $arg_piece; +} diff --git a/nginx-template b/nginx/site.conf similarity index 75% rename from nginx-template rename to nginx/site.conf index 4a161c8..be094f9 100644 --- a/nginx-template +++ b/nginx/site.conf @@ -1,27 +1,7 @@ -# Bot detection for serving pre-rendered meta pages -# This map checks if the user agent is a known crawler/bot -map $http_user_agent $is_bot { - default 0; - ~*bot 1; - ~*crawl 1; - ~*spider 1; - ~*slurp 1; - ~*facebookexternalhit 1; - ~*Facebot 1; - ~*Twitterbot 1; - ~*LinkedInBot 1; - ~*Pinterest 1; - ~*Telegram 1; - ~*WhatsApp 1; - ~*Discord 1; - ~*Slack 1; -} - server { listen 80; server_name your-domain.com; - # RSS feed location = /feed.xml { proxy_pass http://localhost:PORT/generated/feed.xml; proxy_set_header Host $host; @@ -38,7 +18,6 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # Sitemap location = /sitemap.xml { proxy_pass http://localhost:PORT/generated/sitemap.xml; proxy_set_header Host $host; @@ -55,7 +34,6 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # Robots location = /robots.txt { proxy_pass http://localhost:PORT/robots.txt; proxy_set_header Host $host; @@ -72,7 +50,6 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # Generated assets (meta images, 502 page, etc.) location /generated/ { proxy_pass http://localhost:PORT/generated/; proxy_set_header Host $host; @@ -81,19 +58,15 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # Custom 502 error page (served from persistent location outside container) - # Replace YOUR_PROJECT_NAME with your project name error_page 502 /502.html; location = /502.html { root /var/www/YOUR_PROJECT_NAME-static; internal; } - # Serve pre-rendered meta pages to bots for social card previews - # Homepage for bots location = / { if ($is_bot) { - rewrite ^ /generated/meta/index.html break; + rewrite ^ /generated/meta/index.html last; } proxy_pass http://localhost:PORT; proxy_set_header Host $host; @@ -102,12 +75,27 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # Everything else (with bot detection for piece/page meta pages) + location /reader/ { + if ($reader_meta_slug) { + rewrite ^ /generated/meta/$reader_meta_slug.html last; + } + if ($is_bot) { + rewrite ^ /generated/meta/index.html last; + } + + proxy_pass http://localhost:PORT; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_intercept_errors on; + error_page 404 = /index.html; + } + location / { - # For bots: try to serve pre-rendered meta page - # The meta pages are at /generated/meta/{slug}.html if ($is_bot) { - rewrite ^/([^/]+)/?$ /generated/meta/$1.html break; + rewrite ^/([^/]+)/?$ /generated/meta/$1.html last; } proxy_pass http://localhost:PORT; @@ -120,7 +108,6 @@ server { error_page 404 = /index.html; } - # SPA fallback location = /index.html { proxy_pass http://localhost:PORT; proxy_set_header Host $host; @@ -128,4 +115,4 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } -} \ No newline at end of file +} diff --git a/public/content/pages/body-of-work.md b/public/content/pages/body-of-work.md index 652c63f..b120f54 100644 --- a/public/content/pages/body-of-work.md +++ b/public/content/pages/body-of-work.md @@ -1,7 +1,7 @@ --- title: "Body of Work" slug: "body-of-work" -date: 2025-12-27T13:41:06.410Z +date: 2026-02-25T22:31:25.437Z --- ### January 1826 diff --git a/vercel.json b/vercel.json index d3719e0..222f7c2 100644 --- a/vercel.json +++ b/vercel.json @@ -47,6 +47,33 @@ ], "destination": "/generated/meta/:slug.html" }, + { + "source": "/reader/:collection", + "has": [ + { + "type": "header", + "key": "user-agent", + "value": ".*(bot|crawl|spider|slurp|facebookexternalhit|facebot|twitterbot|linkedinbot|pinterest|telegram|whatsapp|discord|slack).*" + }, + { + "type": "query", + "key": "piece", + "value": "(?.*)" + } + ], + "destination": "/generated/meta/:piece.html" + }, + { + "source": "/reader/:collection", + "has": [ + { + "type": "header", + "key": "user-agent", + "value": ".*(bot|crawl|spider|slurp|facebookexternalhit|facebot|twitterbot|linkedinbot|pinterest|telegram|whatsapp|discord|slack).*" + } + ], + "destination": "/generated/meta/index.html" + }, { "source": "/(.*)", "destination": "/index.html" From 65f3759cfc427225c315efd83892f3369dfb8876 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Thu, 26 Feb 2026 04:26:04 +0530 Subject: [PATCH 11/13] fix: improve handling of pages, permission issues as well as a custom description field for Body of Work --- build/defaults/config.yaml | 3 ++- build/generate-meta-pages.ts | 50 ++++++++++++++++++++++++++++++++---- build/generate-og-images.ts | 2 +- public/config.yaml | 1 + 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/build/defaults/config.yaml b/build/defaults/config.yaml index 6abb6c7..7fdc163 100644 --- a/build/defaults/config.yaml +++ b/build/defaults/config.yaml @@ -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." \ No newline at end of file diff --git a/build/generate-meta-pages.ts b/build/generate-meta-pages.ts index a87cd09..fee6f4a 100644 --- a/build/generate-meta-pages.ts +++ b/build/generate-meta-pages.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import yaml from 'js-yaml'; +import fm from 'front-matter'; import { loadTheme, applyOverrides, ThemeConfig, ThemeOverrides } from './utils/theme-loader'; interface Piece { @@ -32,6 +33,9 @@ interface Config { overrides?: ThemeOverrides; }; }; + bodyOfWork?: { + description?: string; + }; } const publicDir = path.join(process.cwd(), 'public'); @@ -69,6 +73,31 @@ const siteTitle = config.site.title; const siteAuthor = config.site.author; const siteDescription = config.site.description || config.site.tagline || ''; +function stripMarkdown(text: string): string { + return text + .replace(/^#+\s+/gm, '') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/_(.+?)_/g, '$1') + .replace(/`(.+?)`/g, '$1') + .replace(/\[(.+?)\]\(.+?\)/g, '$1') + .replace(/!\[.*?\]\(.+?\)/g, '') + .replace(/>\s+/g, '') + .replace(/\n+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function getContentDescription(filePath: string): string { + if (!fs.existsSync(filePath)) { + return ''; + } + const raw = fs.readFileSync(filePath, 'utf-8'); + const parsed = fm(raw); + const body = stripMarkdown(parsed.body); + return body.substring(0, 160); +} + function generateMetaPage( slug: string, title: string, @@ -154,7 +183,7 @@ function generateMetaPage( `; - fs.writeFileSync(path.join(metaDir, `${slug}.html`), html); + fs.writeFileSync(path.join(metaDir, `${slug}.html`), html, { mode: 0o644 }); } function main() { @@ -172,9 +201,11 @@ function main() { const pieces: Piece[] = JSON.parse(fs.readFileSync(piecesPath, 'utf-8')); for (const piece of pieces) { - const description = piece.collections?.length - ? `${piece.collections.join(', ')} by ${siteAuthor}` - : `A piece by ${siteAuthor}`; + const pieceFilePath = path.join(publicDir, 'content', 'pieces', `${piece.slug}.md`); + const description = getContentDescription(pieceFilePath) || + (piece.collections?.length + ? `${piece.collections.join(', ')} by ${siteAuthor}` + : `A piece by ${siteAuthor}`); generateMetaPage( piece.slug, @@ -192,10 +223,19 @@ function main() { const pages: Page[] = JSON.parse(fs.readFileSync(pagesPath, 'utf-8')); for (const page of pages) { + let description: string; + + if (page.slug === 'body-of-work' && config.bodyOfWork?.description) { + description = config.bodyOfWork.description; + } else { + const pageFilePath = path.join(publicDir, 'content', 'pages', `${page.slug}.md`); + description = getContentDescription(pageFilePath) || `${page.title} - ${siteTitle}`; + } + generateMetaPage( page.slug, page.title, - `${page.title} - ${siteTitle}`, + description, page.slug, `/${page.slug}` ); diff --git a/build/generate-og-images.ts b/build/generate-og-images.ts index 084f337..3b2a0c1 100644 --- a/build/generate-og-images.ts +++ b/build/generate-og-images.ts @@ -203,7 +203,7 @@ async function generateOgImage( const pngData = resvg.render(); const pngBuffer = pngData.asPng(); - fs.writeFileSync(path.join(ogDir, `${slug}.png`), pngBuffer); + fs.writeFileSync(path.join(ogDir, `${slug}.png`), pngBuffer, { mode: 0o644 }); } async function main() { diff --git a/public/config.yaml b/public/config.yaml index e81f1dc..dcfd61e 100644 --- a/public/config.yaml +++ b/public/config.yaml @@ -47,6 +47,7 @@ rss: piecesLimit: 10 bodyOfWork: order: ascending + description: "A chronological archive of all pieces, organized by month and year." redeployPage: title: "Just a moment..." message: "Something new is coming along." From d16622d713ecc00b62aa103d2f321a3f1b31f4e1 Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Thu, 26 Feb 2026 04:30:58 +0530 Subject: [PATCH 12/13] fix: use a template for meta pages instead of inlining HTML --- build/generate-meta-pages.ts | 86 +++++++--------------------------- build/templates/meta-page.html | 65 +++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 70 deletions(-) create mode 100644 build/templates/meta-page.html diff --git a/build/generate-meta-pages.ts b/build/generate-meta-pages.ts index fee6f4a..c294ade 100644 --- a/build/generate-meta-pages.ts +++ b/build/generate-meta-pages.ts @@ -41,6 +41,7 @@ interface Config { const publicDir = path.join(process.cwd(), 'public'); const generatedDir = path.join(publicDir, 'generated'); const metaDir = path.join(generatedDir, 'meta'); +const templatePath = path.join(__dirname, 'templates', 'meta-page.html'); if (!fs.existsSync(metaDir)) { fs.mkdirSync(metaDir, { recursive: true }); @@ -111,77 +112,22 @@ function generateMetaPage( const canonicalUrl = `${siteUrl}${canonicalPath}`; const ogImageUrl = `${siteUrl}/generated/og/${ogImageSlug}.png`; - const html = ` - - - - - ${fullTitle} + const template = fs.readFileSync(templatePath, 'utf-8'); - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

${displayTitle}

-

${displayDescription}

-

Continue to ${applyCase(siteTitle)}

-
- -`; + const html = template + .replace(/\{\{fullTitle\}\}/g, fullTitle) + .replace(/\{\{title\}\}/g, displayTitle) + .replace(/\{\{description\}\}/g, displayDescription) + .replace(/\{\{author\}\}/g, siteAuthor) + .replace(/\{\{canonicalUrl\}\}/g, canonicalUrl) + .replace(/\{\{ogImageUrl\}\}/g, ogImageUrl) + .replace(/\{\{siteName\}\}/g, applyCase(siteTitle)) + .replace(/\{\{themeColor\}\}/g, colors.primary) + .replace(/\{\{fontFamily\}\}/g, theme.font.family) + .replace(/\{\{fontFallback\}\}/g, theme.font.fallback) + .replace(/\{\{backgroundColor\}\}/g, colors.background) + .replace(/\{\{textColor\}\}/g, colors.text) + .replace(/\{\{primaryColor\}\}/g, colors.primary); fs.writeFileSync(path.join(metaDir, `${slug}.html`), html, { mode: 0o644 }); } diff --git a/build/templates/meta-page.html b/build/templates/meta-page.html new file mode 100644 index 0000000..f1b7267 --- /dev/null +++ b/build/templates/meta-page.html @@ -0,0 +1,65 @@ + + + + + + {{fullTitle}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

{{title}}

+

{{description}}

+

Continue to {{siteName}}

+
+ + From ec5730e53e9b604a4971abdabf74293bbe3d6b4f Mon Sep 17 00:00:00 2001 From: deepanshkhurana Date: Thu, 26 Feb 2026 05:04:37 +0530 Subject: [PATCH 13/13] chore: dev version bump + prep release for v1.4.0 --- CHANGELOG.md | 25 ++++++++++++++++++++----- docsite/docusaurus.config.ts | 2 +- docsite/package.json | 2 +- package.json | 2 +- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 937ff90..75cf436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,21 +5,36 @@ 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.3.0] - 2026-02-26 +## [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. -- Theme loader utility for build scripts. +- 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. + +Ode's 502 error page template + +_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. -- 502 page served from persistent host location (survives container restarts). +- 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. +- Numeric values in `config.yaml` (e.g., `404`) now handled correctly. ([#17](https://github.com/DeepanshKhurana/ode/issues/17)) ### Removed diff --git a/docsite/docusaurus.config.ts b/docsite/docusaurus.config.ts index 8c14d6b..9588f25 100644 --- a/docsite/docusaurus.config.ts +++ b/docsite/docusaurus.config.ts @@ -123,7 +123,7 @@ const config: Config = { { type: 'html', position: 'right', - value: 'v1.3.0', + value: 'v1.4.0', }, { href: 'https://demo.ode.dimwit.me/', diff --git a/docsite/package.json b/docsite/package.json index b2c00ec..02e59f4 100644 --- a/docsite/package.json +++ b/docsite/package.json @@ -1,6 +1,6 @@ { "name": "docsite", - "version": "1.3.0", + "version": "1.4.0", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/package.json b/package.json index 8c46b90..64e6394 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ode", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "scripts": { "dev": "vite",