Table of Contents


First Start — Where to Begin

Before publishing posts — configure three things in src/config/theme.ts. Everything else can be changed gradually.

Step 1 — Name and Description

export const site = {
  name: 'Your Blog Name',           // ← change this
  description: 'Short description', // ← change this
  version: 'v0.1.0',                // ← keep or remove ('')
  foundedYear: 2026 as number | undefined, // ← your founding year or undefined
  umamiWebsiteId: undefined as string | undefined, // leave for now
} as const;

Find footer.links and replace with your own:

links: [
  {
    href:  'https://t.me/your_channel',
    icon:  'telegram',
    label: 'Telegram',
  },
  {
    href:  '/rss.xml',
    icon:  'rss',
    label: 'RSS Feed',
  },
],

Icons are SVG files from the src/icons/ folder. Specify the filename without extension.

Step 3 — Sidebar Navigation

Find navigation and update the “About” page link:

export const navigation: NavLink[] = [
  { href: '/p/about',  label: 'Моя страница', labelEn: 'About',  icon: 'about'  },
  { href: '/',         label: 'Лента',         labelEn: 'Feed',   icon: 'feed'   },
  { href: '/search',   label: 'Поиск',         labelEn: 'Search', icon: 'search' },
];

Edit src/content/en/pages/about.md — this is your “About” page.

What NOT to Touch on First Launch

These blocks are already configured correctly — do not delete or change them without a reason:

WhatWhy
fonts.faces[]Roboto and Fira Code fonts are connected and cached
colorsDark/light theme already works
categories[]All categories (post, video, book, game, photo) are configured
ui (ru/en)Interface strings are localized
Helpers at the end of the fileUtility functions — do not change

Structure of theme.ts

1.  site          — name, version, year, Umami ID
2.  typography    — font sizes for articles and paragraphs
3.  spacing       — gaps between blocks
4.  colors        — dark and light theme colors
5.  textColors    — colors for individual elements (feed, article, navigation)
6.  layout        — page and sidebar width
7.  sidebar       — icons, padding, theme button
8.  header        — header, logo size
9.  langToggle    — language toggle (hidden by default)
10. categories    — categories: post, video, book, game, photo
11. navigation    — sidebar links
12. shortNotes    — short notes behavior
13. stats         — statistics page
14. feed          — feed: post count, row typography
15. shortNote     — note card styling
16. feedTags      — tags in feed and articles
17. fonts         — fonts: families, woff2 files
18. footer        — footer: icons, links
19. ui            — interface strings (ru/en)
20. faqLink       — FAQ link in footer
21. gdprLink      — Privacy link in footer (hidden by default)

Site Settings

Everything is configured in one file: src/config/theme.ts.

Name, Description, Version

export const site = {
  name: 'The Seventy Eight',   // blog name in the header and <title> tags
  description: 'A diary of the present',
  version: 'v0.5.0',           // version string — shown in the footer
  foundedYear: 2023 as number | undefined,
  umamiWebsiteId: undefined as string | undefined,
} as const;

name — appears in the site header and in the <title> of every page.

version — a string you assign yourself. Shown in the footer on the right. Remove it by setting an empty string ''.

Founded Year

The foundedYear field controls the year range in the footer:

// Shows «2023 – 2026» (when current year ≠ 2023)
foundedYear: 2023 as number | undefined,

// Shows only the current year «2026»
foundedYear: undefined,

No manual updates needed — the right year is taken automatically from new Date().getFullYear().

Umami Website ID

After setting up Umami (see Umami — Analytics), paste the ID from the dashboard:

// No analytics (default)
umamiWebsiteId: undefined as string | undefined,

// With analytics — paste the real ID from Umami Settings → Websites
umamiWebsiteId: 'a13c1aa1-7e44-41a6-9844-1d0bebbc4111' as string | undefined,

Copy the ID from Umami dashboard → Settings → Websites → select site → Website ID.

The Umami script is not loaded on noindex pages (search, categories, tags, pagination) — only on real publications. Personal pages and drafts are not tracked.


Appearance — Colors

Dark and Light Theme Colors

The colors section in theme.ts:

export const colors = {
  // ── Dark theme (default) ──
  bg:          '#0f0f0f',   // page background
  surface:     '#1a1a1a',   // card backgrounds, code, inputs
  border:      '#2e2e2e',   // borders, dividers
  text:        '#e2e2e2',   // main text
  textMuted:   '#888888',   // secondary text (dates, meta)
  accent:      '#c5c5c6',   // accent color (hover links, icons)

  // ── Light theme ──
  bgLight:          '#f5f4f8',
  surfaceLight:     '#eceaf2',
  borderLight:      '#d8d5e6',
  textLight:        '#111111',
  textMutedLight:   '#4a4a4a',
  accentLight:      '#171717',
} as const;

Example: make the accent color bright blue

// Before
accent: '#c5c5c6',

// After
accent: '#5b8dee',

Example: warm white background for light theme

bgLight:      '#faf9f7',
surfaceLight: '#f0ede8',
borderLight:  '#dedad2',

Context Colors

The textColors section lets you set colors for specific elements independently of the theme. By default they reference theme variables (var(--text), var(--accent)).

export const textColors = {
  // Feed
  feedTitle:       'var(--text)',        // post title
  feedTitleHover:  'var(--accent)',      // post title on hover
  feedDate:        'var(--text-muted)',  // date on the left
  feedDateHover:   'var(--accent)',

  // Article
  articleTitle:    'var(--text)',
  articleLink:     'var(--accent)',      // links in the text

  // Navigation
  navLink:         'var(--text-muted)',
  navLinkHover:    'var(--text)',
  navLinkActive:   'var(--accent)',

  // Header and footer
  logo:            'var(--accent)',
  footer:          'var(--text-muted)',
} as const;

Example: fix link color regardless of theme

// Before — color changes when switching themes
articleLink: 'var(--accent)',

// After — always blue, in any theme
articleLink: '#4a90e2',

Category Colors

Each category has its own color used in the navigation:

{
  id: 'post',
  label: 'Posts',
  icon: 'post',
  color: '#7b9cff',   // ← color for this category's icon
  ...
},

Appearance — Typography & Fonts

Font Sizes

The typography section in theme.ts:

export const typography = {
  base: '16px',            // base size — used as html font-size

  // Headings inside articles
  h2: '1.3rem',

  // Article page
  articleTitle: '1.75rem',
  articleBody:  '1rem',
  articleMeta:  '0.875rem',  // date, meta information
  codeInline:   '0.9em',
  codeBlock:    '0.875rem',

  // Paragraphs
  p:             '1rem',
  pLineHeight:   1.7,
  pMarginBottom: '1rem',

  lineHeight: {
    normal:  1.5,    // header, sidebar
    relaxed: 1.75,   // articles
  },
} as const;

Example: make article text slightly larger

// Before
articleBody: '1rem',

// After — slightly larger, especially comfortable on mobile
articleBody: '1.05rem',

Adding a Custom Font

Steps:

  1. Download a .woff2 file (e.g., from gwfh.mranftl.com)
  2. Place it in public/fonts/inter-regular.woff2
  3. Add an entry to fonts.faces:
faces: [
  { family: 'Inter', src: '/fonts/inter-regular.woff2', weight: '400', style: 'normal', preload: true },
  { family: 'Inter', src: '/fonts/inter-bold.woff2',    weight: '700', style: 'normal' },
]
  1. Set the font in fonts.families:
families: {
  body:    "'Inter', system-ui, sans-serif",
  heading: "'Inter', system-ui, sans-serif",
  code:    "'Fira Code', ui-monospace, monospace",
  ...
}

Different Fonts for RU and EN

If you want different fonts for Russian and English pages:

families: {
  body:      "'Roboto', system-ui, sans-serif",    // for RU
  bodyEn:    "'Inter', system-ui, sans-serif",      // for EN (if undefined — uses body)
  heading:   "'Roboto', system-ui, sans-serif",
  headingEn: undefined,  // EN uses heading (Roboto)
}

Current Fonts

RoleFontFile
Body textRobotopublic/fonts/roboto-v51-*.woff2
Code, datesFira Codepublic/fonts/fira-code-v27-*.woff2

Creating Posts

File Structure

All posts are stored in src/content/ru/ (Russian) and src/content/en/ (English).

src/content/en/
├── post-slug.md       ← post in "post" category (Posts)
├── video-slug.md      ← video
├── book-slug.md       ← books
├── game-slug.md       ← games
├── photo-slug.md      ← gallery
├── short/
│   └── 2026-03-18.md  ← short note
└── pages/
    └── about.md       ← "About" page

The post URL matches the filename: my-post.md/en/article/my-post

All Frontmatter Fields

---
title: Post Title
date: 2026-03-18
description: Short description for search and meta tags
draft: false         # optional (default: false)
searchable: true     # optional (default: true)
category: post
tags: [anime, review, 2026]
cover: /images/my-post-cover.jpg
background: /images/my-post-bg.jpg
---

Article text starts here.

Minimal version — only required fields:

---
title: Post Title
date: 2026-03-18
---

Article text.

Field Descriptions

title — post title. Required.

date — publication date in YYYY-MM-DD format. Determines order in the feed.

description — short description. Shown in search and in <meta description>. If not set — the beginning of the text is used.

draft: true — draft. The post does not appear in the feed, search, or RSS. Accessible via direct link if you know the URL.

# Draft — hidden everywhere
draft: true

# Published (default)
draft: false

searchable: false — the post is visible in the feed and RSS, but does not appear in search (/search). Useful for posts that are better read in the context of the feed.

# Exclude from search, but keep in feed
searchable: false

category — post category. Available values:

ValueSectionURL
postPosts/en/post/
videoVideo/en/video/
bookBooks/en/book/
gameGames/en/game/
photoGallery/en/photo/
# Category «Books»
category: book

tags — list of tags. Syntax:

# Correct — square brackets
tags: [anime, review, 2026]

# Correct — with spaces in quotes
tags: ["my thoughts", anime, 2026]

# Correct — block style
tags:
  - anime
  - review

# WRONG — unclosed quote (Astro will not load the file!)
tags: ["tram, "bus"]

Post Cover

cover — image shown at the start of the article and in Open Graph (link preview).

cover: /images/my-post-cover.jpg

Place the file in public/images/my-post-cover.jpg. Recommended size: 1200×630 px (OG standard), JPEG or WebP.

Feed Background Image

background — a subtle background image for the row in the feed. Visible as an atmospheric background to the right of the title.

background: /images/my-post-bg.jpg

Recommended size: 1600×400 px, wide horizontal image. Format: JPEG 80–85% or WebP.

Background behavior is configured in theme.tsfeed:

rowBgSize:     'cover',          // 'cover' | 'auto 100%' | 'auto 150%'
rowBgPosition: 'right center',   // which edge is visible

Short Notes

Short notes are small posts shown in the main feed as cards.

Creating a Note

File: src/content/en/short/2026-03-18.md

---
date: 2026-03-18
title: Short note title
---

Note text. Thoughts, observations, links.

title is recommended — search works by it, and it shows up in results. If not set — the first 60 characters of text are used in search, which is harder to read.

Without a title (minimum):

---
date: 2026-03-18
---

Note text.

Exclude from search:

---
date: 2026-03-18
searchable: false
---

Short Notes Settings

In theme.tsshortNotes:

export const shortNotes = {
  enabled:    true,    // enable / disable
  showInNav:  true,    // show in menu
  showInFeed: true,    // include in main feed
  label:    'Мысли',   // name in menu (Russian)
  labelEn:  'Notes',
  icon: 'short',       // icon from src/icons/
  path: '/short',
} as const;

Individual note URL: /en/short/2026-03-18 All notes index: /en/short (not indexed by search engines)


Pages

Pages are standalone content: “About”, “Contacts”, this FAQ.

Files: src/content/en/pages/about.md, src/content/en/pages/faq.md

Creating a Page

---
title: About the Blog
date: 2024-01-01
description: Short description for meta tags
summary: Subtitle shown under h1 on the page
draft: false
searchable: true
cover: /images/about-cover.jpg
---

Page text...

URL: /en/p/about, /en/p/faq

In theme.tsnavigation:

export const navigation: NavLink[] = [
  { href: '/p/about',  label: 'Моя страница', labelEn: 'About',  icon: 'about'  },
  { href: '/',         label: 'Лента',         labelEn: 'Feed',   icon: 'feed'   },
  { href: '/search',   label: 'Поиск',         labelEn: 'Search', icon: 'search' },
];

To add a link to your page — add a line to the array. Icon is the SVG filename from src/icons/ (without extension).

In theme.tsfaqLink:

export const faqLink = {
  enabled:  true,         // true — show FAQ link in footer
  href:     '/p/faq',
  icon:     'faq',
  label:    'FAQ',
  labelEn:  'FAQ',
} as const;

Search & Drafts

How Search Works

Search is available at /en/search. It runs on the client via Fuse.js — no server requests.

Search index: /en/search.json (generated at build time).

What’s included in the index:

  • Posts (postEn): non-drafts + searchable: true
  • Pages (pagesEn): non-drafts + searchable: true
  • Short notes (shortEn): only searchable: true

Controlling Indexation

GoalFrontmatter
Visible everywhere (default)nothing to specify
Hide from search, keep in feedsearchable: false
Hide everywhere (draft)draft: true

How robots.txt Works

The file public/robots.txt is already correctly configured:

User-agent: *
Allow: /

Disallow: /ru/search
Disallow: /en/search
Disallow: /ru/page/
Disallow: /en/page/
...
Disallow: /ru/short/$
Disallow: /en/short/$

Sitemap: https://theseventyeight.org/sitemap-index.xml

Individual articles (/en/article/…), pages (/en/p/…), and notes (/en/short/slug) are indexed. Utility pages (search, tags, pagination, category lists) are not indexed.


Nginx — Server Setup

The ready-to-use config is in templates_nginx.conf in the repository root.

Quick Application

# Step 1. Copy to server
scp templates_nginx.conf project@your-server:/tmp/

# Step 2. Apply (Section 2 of the file — the ready config)
sudo cp /tmp/templates_nginx.conf /etc/nginx/conf.d/theseventyeight.conf

# Step 3. Check syntax — REQUIRED
sudo nginx -t
# Expected output:
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful

# Step 4. Apply without stopping the site
sudo systemctl reload nginx

Full Server Config

server {
    server_name theseventyeight.org www.theseventyeight.org;

    root /home/project/nana/dist;
    index index.html;

    charset utf-8;

    gzip              on;
    gzip_vary         on;
    gzip_proxied      any;
    gzip_comp_level   6;
    gzip_min_length   1024;
    gzip_types
        text/html text/css text/plain
        application/javascript application/json
        application/xml application/rss+xml image/svg+xml;

    # Cache — _astro/ and fonts/ forever (hash in filename)
    location /_astro/ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        expires 1y;
    }
    location /images/ {
        add_header Cache-Control "public, max-age=604800";
        expires 7d;
    }
    location /fonts/ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        add_header Access-Control-Allow-Origin "*";
        expires 1y;
    }
    location ~* \.html$ {
        add_header Cache-Control "no-cache";
    }

    # Security headers
    add_header X-Frame-Options             "SAMEORIGIN"                        always;
    add_header X-Content-Type-Options      "nosniff"                           always;
    add_header Referrer-Policy             "strict-origin-when-cross-origin"   always;
    add_header Strict-Transport-Security   "max-age=31536000; includeSubDomains" always;

    # i18n redirects: old URLs → /ru/...
    rewrite ^/article/(.*)$                      /ru/article/$1  permanent;
    rewrite ^/short/(.*)$                        /ru/short/$1    permanent;
    rewrite ^/p/(.*)$                            /ru/p/$1        permanent;
    rewrite ^/tag/(.*)$                          /ru/tag/$1      permanent;
    rewrite ^/page/(.*)$                         /ru/page/$1     permanent;
    rewrite ^/(post|video|book|game|photo)(/.*)?$ /ru/$1$2       permanent;
    rewrite ^/search$                            /ru/search      permanent;
    rewrite ^/stats$                             /ru/stats       permanent;

    location / {
        try_files $uri $uri.html $uri/index.html =404;
    }

    location = /rss.xml {
        add_header Content-Type  "application/rss+xml; charset=utf-8";
        add_header Cache-Control "public, max-age=3600";
    }

    # Umami Analytics (tracking public, dashboard only via SSH)
    location = /analytics/script.js {
        proxy_pass         http://127.0.0.1:3000/script.js;
        proxy_set_header   Host $host;
        add_header         Cache-Control "public, max-age=86400";
    }
    location /analytics/api/ {
        proxy_pass         http://127.0.0.1:3000/api/;
        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 = /site.webmanifest {
        add_header Cache-Control "public, max-age=86400";
        add_header Content-Type  "application/manifest+json";
    }

    error_page 404 /404.html;
    location = /404.html { internal; }

    access_log /var/log/nginx/theseventyeight.access.log;
    error_log  /var/log/nginx/theseventyeight.error.log warn;

    # SSL (managed by Certbot — do not edit manually)
    listen 443 ssl;
    ssl_certificate /etc/letsencrypt/live/theseventyeight.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/theseventyeight.org/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}

# HTTP → HTTPS redirect
server {
    if ($host = theseventyeight.org) {
        return 301 https://$host$request_uri;
    }
    listen 80;
    server_name theseventyeight.org www.theseventyeight.org;
    return 404;
}

Umami — Analytics

Umami is self-hosted analytics. It runs in Docker on the server.

What’s Installed

ComponentLocation
docker-compose.ymlumami/docker-compose.yml
.env (passwords)umami/.envnot in git
.env.exampleumami/.env.example

First Launch

# 1. Go to the umami folder on the server
cd ~/nana/umami

# 2. Copy the environment template
cp .env.example .env

# 3. Generate passwords (always generate fresh ones!)
openssl rand -hex 16   # for POSTGRES_PASSWORD
openssl rand -hex 16   # for APP_SECRET

# 4. Insert passwords into .env
nano .env

# 5. Start
docker compose up -d

# 6. Verify it's running
docker compose ps

docker-compose.yml

services:
  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest
    ports:
      - "127.0.0.1:3000:3000"   # localhost only — not exposed publicly!
    environment:
      DATABASE_URL: postgresql://umami:${POSTGRES_PASSWORD}@db:5432/umami
      APP_SECRET: ${APP_SECRET}
      DISABLE_TELEMETRY: 1
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: umami
      POSTGRES_USER: umami
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - umami-db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U umami"]
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped

volumes:
  umami-db:

.env (template)

POSTGRES_PASSWORD=paste_generated_password_here
APP_SECRET=paste_generated_secret_here

Dashboard Access

The dashboard is not publicly accessible — only via SSH tunnel.

Command (replace with your own values):

ssh -L 3000:localhost:3000 YOUR_USER@SERVER_IP -p YOUR_PORT

Example:

# project — your server username (could be root or anything else)
# 1.2.3.4 — your server IP address (or domain — both work)
# 6543    — your SSH port (standard: 22)
ssh -L 3000:localhost:3000 project@1.2.3.4 -p 6543

To keep the tunnel in the background and return to the terminal — add &:

ssh -L 3000:localhost:3000 project@1.2.3.4 -p 6543 &

Then open in browser: http://localhost:3000

Default login: admin / umamichange immediately after first login!

Connecting to the Blog

  1. Go to Umami → Settings → Websites → Add website
  2. Copy the Website ID
  3. Paste in theme.ts:
umamiWebsiteId: 'your-id-here' as string | undefined,
  1. Rebuild and deploy the blog

Management Commands

# Stop
docker compose down

# Restart
docker compose up -d

# Logs
docker compose logs -f umami

# Update to new version
docker compose pull && docker compose up -d

Deploying to Server

Deployment Structure

The repository lives in ~/nana/ on the server. Deployment = git pull + npm run build.

# Connect to server
ssh project@1.2.3.4 -p 6543

# Go to project folder
cd ~/nana

# Pull latest changes from GitHub
git pull

# Rebuild
npm run build

Automated Deploy via deploy.sh

The deploy.sh script in the repository root automates the process:

./deploy.sh

If the server has local changes and git pull fails — the script will offer to reset them.

Branches

BranchPurpose
mainprimary — deploy to prod from here
releasestable release
# Make sure you're on main
git status

# All new commits go to main
git checkout main
git add .
git commit -m "feat: new post"
git push

Browser Compatibility

The blog was tested on modern browsers. Known issues:

Jelly (LineageOS Browser)

No support — especially on older LineageOS versions. Possible issues with:

  • CSS variables (var(--...))
  • color-mix() (color mixing in themes)
  • JavaScript islands (Preact)

This browser uses an outdated WebKit engine — not worth fixing.

Browser Extensions

Errors like moz-extension://... or chrome-extension://... in the console are extension bugs, not site bugs. Ignore them.

Browser Cache

After deploying a new version, users may have old cached content. HTML pages are intentionally cached with Cache-Control: no-cache — the browser checks freshness on each visit. JavaScript and CSS in /_astro/ are cached forever (the filename contains a hash — it changes on update).

HTTP 304

A 304 Not Modified server response is normal. The browser asks “has the file changed?”, the server replies “no” — the browser uses cache. Not an error.

Swipe Back in Browser

Swipe left (iOS Safari, Chrome Android) works as the browser’s standard back button — supported natively without any code. The “Back to feed” button at the end of articles always returns to the main feed page — regardless of navigation history.