diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c0aa58..75cf436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.0] - 2026-02-26 + +### Added + +#### Metadata ([#14](https://github.com/DeepanshKhurana/ode/issues/14)) +- Social card preview system with OG image generation using `satori` and `@resvg/resvg-js`. +- Pre-rendered meta pages for bots (WhatsApp, LinkedIn, Twitter, Facebook, etc.) with full `og:*` and `twitter:*` tags. Note that the tags work according to bot lists and would not be readily available on an online checker. However, `curl`ing the bots with a `User-Agent` e.g. `curl -H "User-Agent: WhatsApp/2.0"...` can be a good way to test. This works across `nginx` as well as `Vercel`. Configurations for both are provided in the repository as `nginx/` and `vercel.json`. +- Content-based meta descriptions: first 160 characters extracted from markdown content with formatting stripped. +- Reader URL bot support: `/reader/:collection?piece=:slug` serves appropriate meta pages to bots. The page is selected to be the piece the reader is currently reading. +- Custom `bodyOfWork.description` field in `config.yaml` for `body-of-work` page social preview. +- `nginx` configuration templates in `nginx/` directory with setup instructions. +- OG images generated at 1200x630px with theme-specific styling. + +#### 502 Error Page for `nginx` ([#19](https://github.com/DeepanshKhurana/ode/issues/19)) +- Themed 502 error page generation with customizable text via `config.yaml`'s `redeployPage` section. +- The theme respects all settings e.g. `defaultMode`, `lowercase` and overrides enabled in `config.yaml`. +- 502 page served from persistent host location (survives container restarts). +- Configuration settings are available in the `nginx/` directory's base template. + +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. ([#18](https://github.com/DeepanshKhurana/ode/issues/18)) + +### Fixed + +- Numeric values in `config.yaml` (e.g., `404`) now handled correctly. ([#17](https://github.com/DeepanshKhurana/ode/issues/17)) + +### Removed + +- `react-helmet` dependency (using React 19 native meta tags). + ## [1.2.9] - 2026-02-22 ### Changed 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/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 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/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/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 new file mode 100644 index 0000000..1a3f8fe --- /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(`[redeploy]: could not load theme: ${themeName}`); + process.exit(1); +} + +const redeployConfig = config.redeployPage || {}; +const useLowercase = config.ui?.lowercase || false; +const applyCase = (str: string) => useLowercase ? str.toLowerCase() : str; + +const title = applyCase(redeployConfig.title || 'Just a moment...'); +const message = applyCase(redeployConfig.message || "We're updating things behind the scenes."); +const submessage = applyCase(redeployConfig.submessage || 'Please refresh in a few seconds.'); +const refreshInterval = redeployConfig.refreshInterval || 10; +const refreshNotice = applyCase((redeployConfig.refreshNotice || 'This page will refresh automatically in {interval} seconds.').replace('{interval}', String(refreshInterval))); +const siteName = config.site?.name || config.site?.title || 'Ode'; + +function generate502Page(theme: ThemeConfig): string { + const mode = config.ui?.theme?.defaultMode || 'light'; + const colors = theme.colors[mode]; + + const templatePath = path.join(templateDir, '502.html'); + let template = fs.readFileSync(templatePath, 'utf-8'); + + const replacements: Record = { + title, + siteName, + fontUrl: theme.font.url, + fontFamily: theme.font.family, + bgColor: colors.background, + fgColor: colors.text, + accentColor: colors.primary, + mutedColor: colors.grey2, + message, + submessage, + refreshNotice, + refreshInterval: String(refreshInterval), + }; + + for (const [key, value] of Object.entries(replacements)) { + template = template.replace(new RegExp(`{{${key}}}`, 'g'), value); + } + + return template; +} + +const html = generate502Page(theme); +fs.writeFileSync(path.join(generatedDir, '502.html'), html); +console.log('[redeploy]: generated 502.html'); 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 new file mode 100644 index 0000000..c294ade --- /dev/null +++ b/build/generate-meta-pages.ts @@ -0,0 +1,195 @@ +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 { + 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; + }; + }; + bodyOfWork?: { + description?: string; + }; +} + +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 }); +} + +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(`[meta]: 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 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, + 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 template = fs.readFileSync(templatePath, 'utf-8'); + + 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 }); +} + +function main() { + generateMetaPage( + 'index', + siteTitle, + siteDescription, + 'index', + '/' + ); + console.log('[meta]: generated meta/index.html'); + + 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 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, + piece.title, + description, + piece.slug, + `/${piece.slug}` + ); + } + console.log(`[meta]: generated ${pieces.length} piece meta 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) { + 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, + description, + page.slug, + `/${page.slug}` + ); + } + console.log(`[meta]: generated ${pages.length} page meta pages`); + } + + console.log('[meta]: generation complete'); +} + +main(); diff --git a/build/generate-og-images.ts b/build/generate-og-images.ts new file mode 100644 index 0000000..3b2a0c1 --- /dev/null +++ b/build/generate-og-images.ts @@ -0,0 +1,268 @@ +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(`[og-images]: 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')) { + const cssResponse = await fetch(fontUrl, { + headers: { + '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(); + } + + const anyFontMatch = css.match(/src:\s*url\(([^)]+\.ttf)\)/); + if (anyFontMatch) { + const fontResponse = await fetch(anyFontMatch[1]); + return fontResponse.arrayBuffer(); + } + + console.warn('[og-images]: could not find TTF font in Google Fonts CSS, using fallback'); + return null; + } + + if (fs.existsSync(fontUrl) && fontUrl.match(/\.(ttf|otf)$/i)) { + const buffer = fs.readFileSync(fontUrl); + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + } + + 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, + 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', + }, + }, + }, + ], + }, + }) as any, + { + 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, { mode: 0o644 }); +} + +async function main() { + let fontData: ArrayBuffer | null = null; + + try { + fontData = await loadFont(); + if (fontData) { + console.log('[og-images]: loaded custom font'); + } else { + console.log('[og-images]: using fallback font'); + } + } catch (error) { + console.warn('[og-images]: failed to load font, using fallback:', error); + } + + const siteTitle = config.site.title; + const siteAuthor = config.site.author; + const siteTagline = config.site.tagline || ''; + + await generateOgImage(siteTitle, siteTagline, 'index', fontData); + console.log('[og-images]: generated og/index.png'); + + const piecesPath = path.join(generatedDir, 'index', 'pieces.json'); + if (fs.existsSync(piecesPath)) { + const pieces: Piece[] = JSON.parse(fs.readFileSync(piecesPath, 'utf-8')); + + 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`); + } + + const pagesPath = path.join(generatedDir, 'index', 'pages.json'); + if (fs.existsSync(pagesPath)) { + const pages: Page[] = JSON.parse(fs.readFileSync(pagesPath, 'utf-8')); + + 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`); + } + + 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 011acc8..6ba1ee4 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); @@ -37,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); } @@ -50,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; } @@ -108,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 1c7c97c..5b476eb 100644 --- a/build/index-pieces.ts +++ b/build/index-pieces.ts @@ -34,13 +34,14 @@ 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) { - console.log('No files found in the pieces directory.'); + console.log('[pieces]: no files found in pieces directory'); process.exit(0); } @@ -53,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; } @@ -88,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; } @@ -117,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 } = {}; @@ -150,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); @@ -162,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/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/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}}

+
+ + diff --git a/build/utils/theme-loader.ts b/build/utils/theme-loader.ts new file mode 100644 index 0000000..357b39e --- /dev/null +++ b/build/utils/theme-loader.ts @@ -0,0 +1,87 @@ +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(`[theme]: could not parse export in: ${themePath}`); + return null; + } + + const themeObjectStr = defaultExportMatch[1]; + const theme = eval(`(${themeObjectStr})`) as ThemeConfig; + + return theme; + } catch (error) { + console.error(`[theme]: error loading ${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', '')); +} + +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/docsite/docusaurus.config.ts b/docsite/docusaurus.config.ts index e5ede0a..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.2.9', + value: 'v1.4.0', }, { href: 'https://demo.ode.dimwit.me/', diff --git a/docsite/package.json b/docsite/package.json index 9c273b1..02e59f4 100644 --- a/docsite/package.json +++ b/docsite/package.json @@ -1,6 +1,6 @@ { "name": "docsite", - "version": "1.2.7", + "version": "1.4.0", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/nginx-template b/nginx-template deleted file mode 100644 index 93461ec..0000000 --- a/nginx-template +++ /dev/null @@ -1,56 +0,0 @@ -server { - listen 80; - server_name your-domain.com; - - location = /feed/ { - proxy_pass http://localhost:PORT/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 = /sitemap/ { - proxy_pass http://localhost:PORT/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 { - proxy_pass http://localhost:PORT/robots.txt; - 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.txt { - proxy_pass http://localhost:PORT/robots.txt; - 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 / { - 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; - - # SPA routing: - proxy_intercept_errors on; - error_page 404 = /index.html; - } - - location = /index.html { - 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; - } -} \ No newline at end of file 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/site.conf b/nginx/site.conf new file mode 100644 index 0000000..be094f9 --- /dev/null +++ b/nginx/site.conf @@ -0,0 +1,118 @@ +server { + listen 80; + server_name your-domain.com; + + 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/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 = /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 = /sitemap/ { + 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.txt { + proxy_pass http://localhost:PORT/robots.txt; + 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/ { + proxy_pass http://localhost:PORT/robots.txt; + 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 /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; + } + + error_page 502 /502.html; + location = /502.html { + root /var/www/YOUR_PROJECT_NAME-static; + internal; + } + + location = / { + 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; + } + + 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 / { + if ($is_bot) { + rewrite ^/([^/]+)/?$ /generated/meta/$1.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 = /index.html { + 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; + } +} diff --git a/package-lock.json b/package-lock.json index c8f99bc..e4e20a5 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", @@ -21,16 +20,17 @@ }, "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", "@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", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "satori": "^0.19.2", "typescript": "^5.9.3", "vite": "^7.2.4" } @@ -1288,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", @@ -1581,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", @@ -1717,16 +1962,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", @@ -1839,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", @@ -1926,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", @@ -2097,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", @@ -2182,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", @@ -2239,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", @@ -2503,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", @@ -2715,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", @@ -2874,6 +3212,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": { @@ -2959,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", @@ -2992,18 +3342,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 +4044,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", @@ -3775,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", @@ -3788,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", @@ -3885,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", @@ -3895,17 +4249,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 +4290,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", @@ -4175,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", @@ -4245,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", @@ -4303,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", @@ -4386,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", @@ -4684,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 87d79fa..64e6394 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,21 @@ { "name": "ode", "private": true, - "version": "0.0.1", + "version": "1.4.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 && 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", "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:og": "vite-node build/generate-og-images.ts", + "build:meta": "vite-node build/generate-meta-pages.ts", "lint": "eslint .", "preview": "vite preview" }, @@ -22,7 +25,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", @@ -30,16 +32,17 @@ }, "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", "@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", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "satori": "^0.19.2", "typescript": "^5.9.3", "vite": "^7.2.4" } diff --git a/public/config.yaml b/public/config.yaml index 81a2c3b..dcfd61e 100644 --- a/public/config.yaml +++ b/public/config.yaml @@ -47,3 +47,10 @@ 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." + submessage: "Please refresh in a few seconds." + refreshInterval: 10 + refreshNotice: "This page will refresh automatically in {interval} seconds." 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/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/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/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/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; } } diff --git a/vercel.json b/vercel.json index 8cbb440..222f7c2 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", @@ -17,9 +25,104 @@ "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": "/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" } + ], + "headers": [ + { + "source": "/generated/og/(.*)", + "headers": [ + { + "key": "Content-Type", + "value": "image/png" + }, + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + }, + { + "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": [ + { + "key": "Content-Type", + "value": "application/xml" + } + ] + }, + { + "source": "/generated/sitemap.xml", + "headers": [ + { + "key": "Content-Type", + "value": "application/xml" + } + ] + } ] } \ No newline at end of file