// TODO: write actual code

Дубль три: як насправді влаштований цей блог

2026-06-14 | Alex Kucherenko | 9 min read

Роботу зроблено. Будемо вважати це офіційним перезапуском блогу, того самого, який ви зараз читаєте. Часу пішло куди більше, ніж мені хотілося б. Зате воно довело те, що мені й треба було довести: Moku Core справді працює. На ньому я зібрав Moku Web, а вже на Moku Web, ось це все, з рівно таким API і такою увагою до дрібниць, які я люблю.

Чи є баги? Беззаперечно. Але там, де вони зараз, мене все влаштовує. Іноді треба просто зупинитися й сказати собі: достатньо добре.

Два блоги, які я закопав

Спершу був WordPress. Я ненавидів його від початку й до кінця. Боти цілодобово лізли його зламати. Хоча, чесно, могли б і не старатися: він чудово ламався сам. База лягала з причин, яких я так і не зʼясував. Якби вона не падала раз у раз, я майже певен, що блог давно став би хабом з роздачі вірусів: діра на дірі, оновлюй постійно, бо біда. І за все це щастя — сім баксів на місяць. Коротше, я радий, що його більше немає.

Потім була спроба на Next.js. Сам я її не писав. Попросив брата з його дівчиною. Їм — навчальний проєкт, мені — блог. Вийшло так собі. Мені банально не подобалося, як Next робить роутинг і який він сам величезний та неповороткий. Репозиторій ще живий, хоч і припадає пилом з кінця 2023-го.

А напрямок же був рівно той, що треба: статична генерація, статті в маркдауні, компоненти на React. MDX, якщо вже зовсім чесно. Тільки реалізувати це було болісно. Серверні компоненти — окрема сага: воно все одно тягло React на клієнт, а з ним ще купу коду. Але саме тоді я вперше ясно побачив власну архітектуру. Ту саму ідею ідеального блогу, яка зрештою й стала ось цим.

Спочатку рушій, потім блог

Про Moku Core я вже писав окремо: місяць я сидів не над кодом, а над специфікацією ядра. Та стаття закінчилася на сумній ноті, мовляв, другого шару ще немає, і третього немає, усе це поки чиста теорія, і я дуже сподіваюся, що вони зʼявляться.

Зʼявилися. Moku Web — це другий шар, фреймворк. А блог — третій, сам застосунок. Триповерхова вежа з тієї статті тепер стоїть повністю, і ви читаєте її верхній поверх.

Увесь блог — це один виклик createApp. Жодних глобалів, розпханих по кутках: підключаєш плагіни, віддаєш їм конфіг, і на цьому все.

// src/app.ts — the whole blog is one createApp() call.
const app = createApp({
  plugins: [contentPlugin, buildPlugin, deployPlugin, dataPlugin, cliPlugin],
  config: { mode: "hybrid", stage: "production" },
  pluginConfigs: {
    site: SITE,            // name, url, author — one source of truth
    i18n: i18nConfig,      // en · uk · ru · es
    content: {
      providers: [
        fileSystemContent({
          contentDir: "./content",
          shikiTheme: warmSyntaxTheme,                  // code blocks
          mermaid: { mermaidConfig: warmMermaidTheme }, // ```mermaid fences
          embed: { facade: EmbedFacade },               // ::embed{...}
          gallery: { component: Gallery }               // ::gallery{...}
        })
      ]
    },
    router: { routes },
    deploy: { target: "cloudflare-pages" }
  }
});

Один файл — і видно, з чого блог зібраний.

Де що лежить

Решта проєкту така ж пласка. Жодних чарівних папок, жодної прихованої проводки: відкриваєш директорію, і вона робить рівно те, що написано на дверях:

blog/
  src/
    app.ts        # Node-side composition — the createApp() from above
    spa.tsx       # browser entry — same routes, none of the Node plugins
    routes.tsx    # THE route table — one source of truth (next section)
    config.ts     # SITE identity: name, url, author
    i18n/         # en · uk · ru · es — UI strings + locale config
    pages/        # one Preact page per route (home, article, archive…)
    components/   # SSR'd UI: nav, gallery, the embed facade
    islands/      # the tiny client scripts, hydrated on demand
    lib/          # pure helpers: articles, head, urls, dates
    styles/       # CSS: tokens, fonts, article typography
    og/           # build-time OG-image cards (Satori)
  content/        # the articles — one folder per post (see above)
  public/         # served as-is: fonts, _headers, favicons
  scripts/        # one-liners: build / serve / preview / deploy
  tests/          # unit · integration · e2e (the paranoia, below)

Два файли тримають на собі все: app.ts, який ви щойно бачили, і routes.tsx, а от він мені й дорогий.

Роутер — серце всього

Якщо в блогу і є серце, то це routes.tsx. Одна таблиця, і читають її одразу троє: статична збірка (які сторінки генерувати), навігація в браузері (що відмалювати по кліку) і будівник посилань (кожен href на сайті). Описуєш сторінку один раз, в одному місці — і всі троє назавжди згодні між собою.

Маршрут — це невеличкий ланцюжок декларацій:

// src/routes.tsx — define a page once; the build, the SPA, and every link obey it.
article: route("/{lang:?}/{slug}/")          // {lang:?}: bare "/slug/" for en, "/ru/slug/" for the rest
  .generate(async ctx =>                       // which static pages to emit at build time
    (await allArticles(ctx)).map(a => ({ lang: ctx.locale, slug: a.computed.slug }))
  )
  .load(async ctx => {                         // fetch the data — runs at build, persisted as JSON
    const article = await articleBySlug(ctx);
    const all = await allArticles(ctx);
    return { article, related: relatedArticles(all, article, 5) };
  })
  .render(ctx => <ArticlePage article={ctx.data.article} related={ctx.data.related} />)
  .head(ctx => articleHead(ctx, ctx.data.article))   // <title>, OG, canonical, hreflang

.generate каже, які сторінки створювати, по одній на статтю і локаль. .load вантажить дані. .render — це Preact-компонент. .head — сеошка. Ось і весь контракт.

А тепер фокус, через який блог працює як SPA, хоч я й рядка цієї машинерії не написав. На збірці .load відпрацьовує, і результат запікається в HTML і кладеться поряд, як _data/<lang>/<slug>/index.json. Клікаєш по посиланню — браузер не перезавантажує сторінку, а підвантажує цей маленький JSON і ганяє той самий .render. Один код, два моменти: збірка у мене на машині, клік у тебе у вкладці.

А раз посилання беруться з тієї ж таблиці, протухнути вони не можуть:

// links come from the SAME table — a typed builder, so a link can't drift from a route:
urls.toUrl("article", { lang: "en", slug: "spark" }); // "/spark/"     — en is bare
urls.toUrl("article", { lang: "ru", slug: "spark" }); // "/ru/spark/"

URL руками я не пишу жодного разу. Прошу його в таблиці. А перейменую маршрут, і всі посилання переїдуть слідом. Ось чому роутер сидить у центрі, а все решта висить на ньому.

Стаття — це просто папка

Щоб написати пост, я не відкриваю жодну адмінку (привіт, WordPress). Я створюю папку content/<slug>/ і кладу в неї по файлу на кожну мову:

content/how-this-blog-works/
  en.md
  uk.md
  ru.md
  es.md
  images/   # images, if you need them

Зверху в кожного файлу — фронтматтер, звичайний YAML:

---
title: "Take Three: How This Blog Actually Works"
date: "2026-06-14"
description: "One hook of a sentence — it doubles as the OG card and the archive teaser."
tags:
  - moku
  - programming
language: en
draft: false
---

draft: false — і пост їде в прод. Поставиш true — і його видно лише локально, поки він ще пишеться. Жодної кнопки «Опублікувати», жодної бази. Git — це і є моя CMS.

Те, заради чого я це й затівав

А ось та частина, заради якої я взагалі це все робив. Ви любите технічні деталі? Я люблю.

Підсвічування коду. Кожен блок коду в цьому пості розфарбований Shiki на етапі збірки, а не в браузері. Тема тепла, під колір блогу; самі кольори токенів лежать прямо в інлайнових стилях.

Діаграми. Кидаєш блок ```mermaid, і на збірці він перетворюється на статичний SVG. Ось прямо під цим абзацом живий приклад, та сама фіча, про яку я розповідаю. Шлях, який проходить текст, перш ніж стати сайтом:

bun run buildgit pushcontent/*.mden · uk · ru · es@moku-labs/webShiki · Mermaid · ::embed· ::gallerydist/ · static HTML+ tiny islandsCloudflare Pages$0 / month

Ті самі діаграми я малюю в постах про Moku Core і Spark.

Галереї. ::gallery{src="./images/board/"} — і папка з картинками перетворюється на галерею з лайтбоксом. Так я показував коробки настолок у найкращих настільних іграх і в Descent.

Ембеди. ::embed{src="…" title="…"} — це лінивий iframe: поки не клікнеш, нічого не вантажиться. У пості про Screw Master так можна прямо в статті пограти в гру, яку я зробив.

Усе це — директиви: по одному рядку маркдауну на кожну.

Чотири різновиди паранойї

Я не довіряю ні собі, ні тим більше ШІ, що половину всього цього написав. Тому тестів кілька шарів:

  • Юніт-тести — на чисту логіку: пагінація, форматування дат, збірка посилань. Швидкі, без браузера.
  • Інтеграційні — збирають справжній контент, усі статті цілком, і перевіряють, що нічого не відвалилося. Включно з рендером тих самих мермейд-діаграм.
  • E2E на Playwright — ганяють живий зібраний сайт у трьох рушіях: Chromium, WebKit і Firefox. Бо Safari в мене вже ламався окремо, і я навчений.
  • Візуальні бейзлайни — золоті скриншоти головної, архіву та статті. Зсуне код верстку — скриншот не сходиться, і тест падає.

Тонкість: e2e ганяється не по живих статтях, а по замороженому наборі фікстур. Тому я можу публікувати в content/ що завгодно: тести від цього й бровою не ведуть. Новий пост не вимагає жодної правки в тестах.

Хостинг за нуль

Ну і про гроші. Памʼятаєте сім баксів на місяць за WordPress? Тут нуль.

Блог — це просто купа статичних файлів. Я роблю git push, CI запускає лінт і тести, і якщо все зелене — воно само їде на Cloudflare Pages. Статика на безкоштовному тарифі — це буквально нуль на місяць. Жодного сервера, який треба патчити, й оновлювати, й щотижня піднімати базу. Сторінка приїжджає до читача готовим HTML, а інтерактивність — крихітними острівцями (невеликі JS-скрипти, що чіпляються на контент або просто працюють на клієнті).

Із семи доларів у нуль. Не розбагатів, звісно, але заощадити приємно.

***

Ось такий початок. Або кінець — це з якого боку подивитися. Блог, який я переписував тричі (а може, і чотири рази — я збився з ліку), нарешті стоїть на тому фундаменті, який я хотів від самого початку.

А в мене тепер нова іграшка і плюс одне хобі — Spark, рушій на Rust, про який я вже почав розповідати. Тож історія не закінчується. Вона просто переїжджає в інший репозиторій.