Велике оновлення сайту

У вигляді блогу цей сайт існує вже майже 5 років (див. «Привіт, світ»). Весь цей час він працював на маловідомому двигуні під назвою Mosquito Bloody Mary, написаному на PHP одним чуваком з Росії. У 2013-му цей двигун вже був трохи застарілим (останнє оновлення вийшло ше в 2010-му), але тоді я вибрав його, бо він був легеньким, аскетичним і вже знайомим для мене, колись ше в 2009-му в мене був сайт hmmm.pp.ua на Mosquito, я навіть знайшов свій старий логотип :) 

Тоді в 2013-му я зробив шаблон з цим дизайном, українізував двигун, і трохи попідпилював його під свої потреби, іншими словами, напхав костилів (див. «Моя збірка Mosquito Bloody Mary»). І ше дописав пару вбогих PHP-скриптів для Смітника

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

  • Фронт-енд — React (а точніше server-side rendering фреймворк для React під назвою Next.js)
  • Бек-енд — Node.js
  • База — MongoDB
  • Сервер — Nginx
  • ОС — Ubuntu
  • Хостинг — Digital Ocean (Droplet для самого сайту і Space для картинок і статичних файлів)
  • DNS, CDN i SSL провайдер — Cloudflare

Які проблеми були зі старим сайтом

Ціла гора:

  1. Всі зображення завантажувались в одну теку і без дописування жодних ідентифікаторів до назв файлів. Тобто файли з однаковими назвами просто перетирали одне одного. Це була мегатуфта, для всіх картинок я мусив вручну додавати якийсь випадковий суфікс, шоб часом не перетерти старих файлів.
  2. Не можна було завантажити файл на сервер через інтерфейс. Доводилось закидати вручну по FTP.
  3. Не можна було вставити шматок підсвічуваного коду в запис. Треба було закидати шматки коду на Pastebin чи GitHub Gists і потім вставляти їх сюди.
  4. Не можна було вставити математичні формули, наприклад, написані на LaTeX. Доводилось вставляти їх картинками, виглядало теж убого.
  5. Редактор записів для абзаців вставляв теги <br>, а не <p>. Раніше це не було проблемою, але зараз всі браузери відображають цей тег по-різному, з різними відступами по вертикалі, а звести це до якогось однакового вигляду за допомогою CSS в мене не вийшло. У всіх записах відстані між абзацами відрізнялись в залежності від браузера, і це було капець жахливо на вигляд. Набагато жахливіше, ніж звучить.
  6. Адреси записів не можна було зробити людськими типу /p/some-words, було просто /p/40 чи інше число.
  7. Назви сторінок були одночасно їх адресами, тому були проблеми зі сторінками з кириличними назвами і зі спец. символами в назвах.
  8. Всі іконки були растровими і не розрахованими на Retina і інші дисплеї високої чіткості, тому виглядали розмито на таких екранах.
  9. Сайт не був адаптований для мобільних пристроїв.
  10. Смітник був повністю окремою сутністю, ніяк не зв'язаною з самим двигуном. Там навіть не було авторизації, додати запис міг по-суті будь-хто, просто дізнавшись адресу сторінки, де була форма для додавання записів. Посилань на цю сторінку ніде не було, тим не менше 26 жовтня 2016-го року якась таємнича особа таки знайшла цю дірку і успішно скористалась нею :)
  11. Редагувати записи в смітнику теж не можна було, я не зробив такої функції :). Тому коли треба було шось поредагувати, я знову ж таки заходив по FTP на сервер і редагував файл вручну (всі дані, і зі смітника, і з блогу зберігались в файлах, ніякої БД не було).
  12. Оскільки смітник був окремою сутністю, пошук там був теж окремий, тому не можна було шукати шось одночасно і там, і там. По сайту працював вбудований москітівський пошук, а до смітника був прикручений пошук від Google.

І так далі. Проблем було багато, і ніж влазити в двигун і займатись кожною з них, було простіше і логічніше красиво переписати все з нуля на сучасних технологіях і повністю під свої потреби, тим більше, шо я вже давно хотів нормально попрацювати з React і server-side rendering, а по роботі такого досі не випадало.

Шо змінилось в новій версії

Видимих для відвідувача змін насправді мало. Дизайн лишився тим самим, бо він мені подобається.

  1. Тепер сайт має нормально відображатись на маленьких екранах.
  2. Тепер це single-page application, тому всі переходи по сайту відбуваються без перезавантажень сторінки, а тому і швидше (а Next.js ше й підтягує сторінки на задньому плані за допомогою сервіс-воркерів).
  3. Пошук шукає одночасно по записах, сторінках, і смітнику. І трохи змінився вигляд сторінки з результатами пошуку. В кожному результаті відображається шматок тексту з усіма входженнями ключового слова, і ключове слово підсвічується. Крім того результати пошуку сортуються по релевантності. Про пошук буде нижче.
  4. Всі іконки тепер векторні, тому все дуже чотко чітко.
  5. Програмний код в записах класно підсвічується.
  6. Математичні формули в записах тепер теж красиві і чоткі чіткі (дякую за це бібліотеці MathJax).
  7. Коли заходиш в смітник, кришка смітника тепер повністю відкрита і лежить збоку.
  8. Змінились правила малинової лотереї. Раніше при кожному завантаженні сторінки генерувалось два випадкових числа від 1 до 15, одне — щасливе, інше — число відвідувача. Якшо числа співпадали, на тлі сайту відображався гарний візерунок з малиною. Але коли ти йшов на наступну сторінку, числа генерувались наново і тло з великою імовірністю зникало. Тепер числа генеруються раз на день, тому якшо при першому за день вході на сайт тобі пощастило побачити малинове тло, то ти бачитимеш його весь день :). А якшо не пощастило, то вже весь день тло буде сірим. Ну і тепер числа генеруються  не від 1 до 15 (імовірність співпадіння чисел — 0.45%), а від 1 до 5 (це вже хоча б 4%).
  9. Теґи в хмаринці теґів тепер весело гульбанять, коли на них наводиш.
  10. Адреси записів і сторінок тепер красиві і говорять самі за себе. Див. адресний рядок.
  11. Візуально пришвидшилось завантаження зображень (принаймі я так думаю).  Про це буде нижче.
  12. Тепер сайт працює через HTTPS!
  13. ...і через HTTP/2.
  14. Маю надію, шо все завантажується трошки швидше завдяки CDN від Cloudflare.
  15. Я трошки попрацював над SEO i SMO — тепер мета-теги генеруються динамічно для кожного запису чи сторінки, крім того всюди тепер генеруються JSON LD дані (надіюсь, це якось покращить вигляд пошукових результатів в Google) і мета-теги для соц. мереж.
  16. Ну і зникли всі проблеми пов'язані з створенням і редагуванням записів/сторінок, про які я згадував в попередньому списку.

Цікаві моменти

Цікавих моментів (крім тотального задоволення від програмування на божественному джаваскрипті з його неймовірними інструментами) під час переписування для мене було кілька:

  1. Server-side rendering
  2. Вибір WYSIWYG редактора
  3. Текстовий пошук по базі
  4. Візуальне пришвидшення завантажень зображень
  5. Хмаринка теґів

Пройдусь по кожному з них.


Server-side rendering

Цікавість була в тому, шо раніше я ніколи не мав з цим справи, і чесно кажучи не зовсім уявляв, шо саме відбувається на сервері, шо в браузері, і як вони між собою взаємодіють. Ше не дуже розумів, як буде працювати автентифікація. Трохи вникнувши в тему, я почав шукати, як реалізувати SSR з React, і знайшов невимовно гарний фреймворк — Next.js. Мені він дуже сподобався своєю мінімалістичністю, я люблю мінімалістичні фреймворки, які не несуть за собою тонну всякої всячини. Напевно через це я ніколи не зможу всім серцем полюбити Ember.js, хоча він дуже крутий. 

Розробники Next.js навіть зробили сайт для знайомства з фреймворком. Раджу глянути: learnnextjs.com.


Вибір WYSIWYG редактора

Вибрати хороший WYSIWYG редактор для записів виявилось не так просто. В 2013-му варіантів було не так багато, тоді я вибрав редактор nicEdit. В нього є мінуси, про які я згадував вище, крім того він не оновлювався з 2015-го року, тому я шукав інші варіанти. Я знайшов великий оновлюваний список редакторів на всі випадки життя і почав перебирати варіанти з нього. Хотілось вибрати шось мінімалістичне і водночас шоб була можливість допиляти редактор під свої потреби, дописавши свої кнопки/плагіни.

Монстрів типу TinyMCE i CKEdit я відкинув зразу. 

Мені впав в око Quill — він повністю підходив під мої потреби, мав гарний дизайн і багато зірочок на ґітхабі. Виглядав дуже круто і розвинено. Але він має великий і дивний мінус. Цей редактор генерує контент не у HTML, а в якомусь своєму форматі під назвою Deltas, який несумісний ні з чим і ніщо не сумісне з ним. Навіть немає задокументованої можливості відрендерити на сторінці вміст, створений в Quill. Не можна перетворити вміст в HTML і відобразити його на сторінці. По-суті, написане в Quill можна побачити лише в Quill. Або, що було написано в Quill, залишається в Quill :)

З того всього я вибрав jodit. Непоганий редактор, його можна дуже широко кастомізувати, але на жаль там ше дуже багато багів, тому користуватись ним незручно. Його розробляє лише один чувак, і на такий гігантський проект йому, схоже, не вистачає часу.

Далі я зупинився на Froala Editor. За функціоналом він був як дві краплі води схожим на jodit (здається, автор jodit дуже надихається цим редактором), але дуже зручним, стабільним, і з гарним дизайном, проте не open-source. Виявилось, шо редактор, продається за гроші, при чому ліцензія на один домен коштує аж 100 $. Я чесно кажучи не був готовий платити стільки за редактор для нікому не потрібного сайту, де я пишу шось раз в півроку. Пошукав, чи можна на якихось умовах користуватись ним безкоштовно, і прочитав на їх сайті, що так, можна, але в редакторі завжди буде висвічуватись червоний банер з написом «Мудак, купи ліцензію», інших обмежень немає. На таке я був згоден, та й банер легко заховати за допомогою CSS. Я потестував редактор на локалхості, налаштував під свої потреби, українізував (українізація, до речі, є з коробки, але дуже корява) і лишився задоволеним. Але після деплою на сервер виявилось, шо через кожну хвилину-дві редактор самознищується. Просто бах, і зникає. Я знайшов про це issue в них на GitHub, де автори відповіли, шо чувак сам шось нахімічив, і якшо хоче, може звернутись в їхню тех. підтримку, швидко закрили та заблокували issue. Чувак створив ще одне, але його теж зразу ж заблокували і нічого не відписали. Виглядало, шо це не баг, а задуманий спосіб захисту, але автори не хочуть говорити про це привселюдно.

Тому я почав копатись в коді бібліотеки (який доступний тільки в обфускованому вигляді), шоб знайти причину такої поведінки, і як я і очікував, виявилось, шо це робиться навмисно. Врешті-решт, на свій сором, я спіратив цю бібліотеку. Але це було цікаво. Досліджувати обфускований код ше те задоволення, а чуваки не обійшлись лише обфускацією, вони гарно попрацювали над всіма зачіпками, все позакодовували і тд, було цікаво бавитись в хакера і досліджувати їхні загадки. Вкінці-кінців у одному місці бібліотеки досить було додати false &&, шоб все запрацювало як треба. Ясно шо нічого хорошого в піратстві нема. Постараюсь в майбутньому купити цю бібліотеку.


Текстовий пошук по базі

Ше мені було цікаво написати пошук, бо раніше я нічого такого не робив. Піднімати шось складне типу Elasticsearch для такого маленького сайту не було сенсу. Почитав про текстові індекси і оператор $text в Mongo. 

Пошук робиться дуже легко і красиво. Маленький приклад: маємо колекцію posts, кожен post має title, body i tags. І ми хочемо, аби наш пошук шукав по всіх полях, але заголовок мав найбільшу вагу в результатах, текст запису — меншу і теґи — ше меншу. Задаємо для колекції індекс на три поля, ставимо ваги для полів.

const mongoose = require('mongoose');

const schema = new mongoose.Schema({
  title: String,
  body: String,
  tags: [String],
});

schema.index({
  title: 'text',
  body: 'text',
  tags: 'text'
}, {
  weights: {
    title: 10,
    body: 5,
    tags: 1,
  }
});

const model = mongoose.model('Post', schema);

module.exports = model;

Тепер уявімо такий запис:

{
  title: 'Історія про печенько',
  body: 'На столі лежало печенько. Коші скрикнув, а тоді підійшов, взяв, і з\'їв це смачнюче, велике, запашне шоколадне печенько з варенням, а потім одразу ж виблював його, і всі не на жарт здивувались. «Що ж це за печенько таке?» — спитав Семюель здивовано. Але ніхто не знав, хто саме приніс це печенько, бо воно було таємничим, як і сам Коші.',
  tags: ['печенько', 'дивина'],
}

Пошук записів зі словом «печенько» виглядатиме якось так:

const postModel = require('../models/post');

postModel.find({
  $text: {
    $search: 'печенько',
  }, {
    score: {
      $meta: 'textScore',
    },
  },
})
  .then(postsAboutCookies => console.log(postsAboutCookies));

Отримаємо всі записи, де в заголовку, тексті чи теґах згадується «печенько», крім того всі результати матимуть додаткову властивість score, в якій буде число, яке позначатиме релевантність цього результату. 

Наприклад цей запис про печенько матиме \(score = 1 \cdot 10 + 4 \cdot 5 + 1 \cdot 1 = 31 \) (бо в заголовку, який має вагу 10, ключове слово трапилось один раз, в тексті, який має вагу 5 — 4 рази, і в тегах, які мають вагу 1 — один раз). Насправді там трохи інша формула, це спрощено, але начхати, суть та сама.


Візуальне пришвидшення завантаження зображень

Я пробував зробити шось таке, як на medium.com. Наче вийшло, але працює не ідеально, треба буде допилювати. Виглядає якось так:

Працює досить просто — спершу я завантажую прев'юшки зображень, паралельно починаю ванатжити оригінали, і коли оригінал готовий, вставляю його замість прев'ю. Прев'ю генеруються на льоту, до адреси зображення я додаю :preview, всі запити до зображень проходять спершу через мій проксі-сервер, і якшо в запиті є такий параметр, то, завантаживши картинку з Digital Ocean, я зменшую її до 550 пікселів по ширині (максимальна ширина контенту на цьому сайті), стискаю і розмиваю.

Для обробки зображень я взяв npm бібліотеку sharp, для такого завдання вона набагато швидша, ніж GraphicsMagick. Запити до зображень працюють через стріми, шоб не захаращувати оперативну пам'ять. sharp підтримує роботу зі стрімами.

const express = require('express');
const request = require('request');
const mime = require('mime-types');
const sharp = require('sharp');
const config = require('../config').current;

const router = express.Router();

const PREVIEW_PARAM = ':preview';
const PREVIEW_WIDTH = 550;
const PREVIEW_BLUR = 25;

function generatePreview() {
  return sharp()
    .resize(PREVIEW_WIDTH, null)
    .withoutEnlargement()
    .blur(PREVIEW_BLUR);
}

router.get('/:filename', (req, res, next) => {
  const { filename } = req.params;
  const previewRequested = filename.includes(PREVIEW_PARAM);
  const originalFilename = previewRequested ? filename.replace(PREVIEW_PARAM, '') : filename;
  const originalURL = `https://${config.digitalOcean.spaces.name}.${config.digitalOcean.spaces.endpoint}/${config.environment}/images/${originalFilename}`;

  res.header({
    'Content-Disposition': 'inline',
    'Content-Type': mime.lookup(originalFilename),
  });

  if (previewRequested) {
    return request(originalURL)
      .pipe(generatePreview())
      .pipe(res);
  }

  return request(originalURL)
    .pipe(res);
});

module.exports = router;

А на фронт-енді є такий сервіс з двома функціями, одна проходиться по HTML і до всіх посилань на картинки додає :preview, а інша так само проходиться по HTML, створює елемент img для кожної прев'юшки, починає вантажити туди оригінал, і коли оригінал готовий, ставить його замість прев'ю.

import { getImageLinksFromHTML } from './text';

const PREVIEW_PARAM = ':preview';

export function replaceOriginalImagesWithPreviews(html) {
  const imageLinks = getImageLinksFromHTML(html);

  return imageLinks.reduce((result, link, index) =>
    result.replace(`src="${link}"`, `src="${link}${PREVIEW_PARAM}" data-preview-id="${index}"`), html);
}

export function loadOriginalImages(html) {
  const imageLinks = getImageLinksFromHTML(html);

  imageLinks.forEach((link, index) => {
    const previewImage = global.document.querySelector(`img[src="${link}${PREVIEW_PARAM}"][data-preview-id="${index}"]`);
    const originalImage = global.document.createElement('img');

    originalImage.onload = function () {
      previewImage.src = this.src;
    };
    originalImage.src = link;
  });
}

export default { replaceOriginalImagesWithPreviews, loadOriginalImages };

В компоненті це якось так виглядає:

import React from 'react';
import * as ImagePreviews from '../services/image-previews';

class Post extends React.Component {
  constructor(props) {
    super(props);

    const bodyWithPreviews = ImagePreviews.replaceOriginalImagesWithPreviews(props.body);
    
    this.state = {
      body: bodyWithPreviews,
    };
  }

  componentDidMount() {
    ImagePreviews.loadOriginalImages(this.props.body);
  }

  render() {
    return (
      <article>
        <h1>{this.props.title}</h1>
        <div>{this.state.body}</div>
      </article>
    );
  }
}

export default Post;

Виграш по розміру в середньому виходить суттєвий, наприклад оце зображення в оригіналі займає 1.1 МБ, а його прев'ю — лише 6 КБ. Але це все одно не найкращий спосіб реалізувати таку штуку, наприклад на Медіумі вони завантажують зовсім маленьку копію зображення, і після цього вже в браузері розмивають її за допомогою CSS фільтру і розтягують зображення до оригінального розміру. Так зображення займає ше менше + сервер не витрачає час і ресурси на обробку зображення. Я не зміг так зробити, бо в браузері мені не відомий розмір оригінального зображення, відповідно я не знаю, як його треба збільшити. Можна звісно зберігати в базі розміри всіх картинок, але… Ну до біса, візуально виграш і так є. 

Я не був певен, чи ці зміни на краще бо тепер робиться вдвічі більше запитів, і до часу кожного запиту додається час на обробку зображення на сервері. Подивимось, може і заберу це до біса. Можна просто рендерити одноколірні прямокутнички.


Хмаринка теґів

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

Як працює анімація хитання? При наведенні курсора на слово, я випадковим чином генерую якийсь кут, це буде напрям коливання для цього слова. Далі можемо задати якусь сталу амплітуду.

let angle = random({ min: 0, max: 2 * Math.PI });
const amplitude = 10;

Тепер маючи кут \( \alpha \) і амплітуду \( l \), можна порахувати, яким має бути початкове зміщення по \(x\) i \(y\):

Не треба бути доктором фізико-математичних наук, шоб побачити, шо \( x = l \cdot cos(\alpha) \), а \( y = l \cdot sin(\alpha) \). Тепер якшо кожних, скажімо, 150 мілісекунд додавати до кута \( \pi \) радіанів (180 градусів) і перераховувати \( x \) та \( y \), то наше слово буде коливатись туди-сюди з амплітудою 10 пікселів вічно.

function shake($element) {
  const amplitude = 10;
  let angle = random({ min: 0, max: 2 * Math.PI });
  
  setInterval(() => {
    const x = amplitude * Math.cos(angle);
    const y = amplitude * Math.sin(angle),
    
    $element.css('transform', `translate(${x}px, ${y}px)`);
    
    angle += Math.PI;
  }, 150);
}

Шоб воно не робило цього вічно, коливання треба гасити, тобто треба, шоб амплітуда з часом зменшувалась. Ну це наче неважко. Ше треба не забути про умову виходу з циклу, де треба вернути елемент на місце і зупинити інтервал, шоб не засмічувався event loop.

function shake($element) {
  let amplitude = 10;
  let angle = random({ min: 0, max: 2 * Math.PI });
  
  const interval = setInterval(() => {
    if (amplitude <= 0) {
      $element.css('transform', `translate(0, 0)`);
      clearInterval(interval);
      return;
    }

    const x = amplitude * Math.cos(angle);
    const y = amplitude * Math.sin(angle),
    
    $element.css('transform', `translate(${x}px, ${y}px)`);
    
    angle += Math.PI;
    amplitude -= 1;
  }, 150);
}

Та й все. Я ше трохи побавився з гасінням коливань, мені не подобалось гасити їх так лінійно, тому я взяв за основу функцію \( \frac{1}{log_{10}x} \) (вона дуже плавно спадає), але ввів туди ше вагу слова, шоб більші слова коливались довше (вага теґу в хмаринці теґів — це кількість записів з таким теґом; правда я ше нормалізую вагу, шоб не було великих розривів між вагами, типу, шоб не було теґів з вагою 100 і теґів з вагою 1). А step це змінна циклу.

const amplitude = Math.cbrt(weight) / Math.log10(step);

Ше довелось змінити умову виходу, бо функція  \( \frac{1}{log_{10}x} \) прямує до нуля, але ніколи туди не доходить. Тому я чекаю, поки амплітуда падає нижче 0.8 пікселя.


Хай живе дивовижний JavaScript i зокрема React, мені сподобалось :)

Весь код тут, якшо комусь цікаво https://github.com/poohitan-blog