Обновление сайта, версия 4.0

6 минут

С момента последнего крупного обновления сайта прошло более 10 лет. Пришло время обновиться.

Маленькая историческая справка🔗︎

Первая версия сайта была написана на HTML + PHP в 2009 году, тогда я только учился писать сайты. Выглядела она так (взято и вебархива):

first_version

Позже я захотел получить больше функционала и перенёс сайт на движок PHP-Fusion. Потом был переход на хайповый в то время WordPress - движок, на котором этот сайт продолжал работать до текущего момента.

last_version

Почему не WordPress🔗︎

Когда я выбирал движок для сайта, мне казалось, что все функции, которые предоставляются WordPress мне понадобятся, но это оказалось не так. Множество функций просто не нужны или даже мешают.

А для меня, как разработчика, количество ненужных функций ещё больше:

  • WYSIWYG редактор хуже, чем Markdown разметка.
  • Функции админки просто не используются.
  • Встроенные в WordPress комментарии не пригодны для использования (никак не защититься от спама).
  • Постоянно находятся дыры безопасности в WordPress, из-за чего его приходится регулярно обновлять.
  • Для работы требуется MySQL и PHP, что усложняет развёртывание и бекапы сайта.

Альтернативы🔗︎

Сейчас для создания домашней странички гораздо больше выбора чем раньше. Например, появились JavaScript технологии вроде Next/Nuxt/SvelteKit, с помощью которых можно используя только JavaScript написать одновременно фронтенд и бэкенд для сайта. Но для личной страницы это тоже перебор - сложной логики на сайте нет и не предвидится.

Лучшим выбором для личной страницы становятся генераторы статических сайтов. Это такие сборщики, которые на базе шаблонов и файлов конфигурации генерируют сайт, который представляет из себя набор html страниц.

Статические сайты имеют следующие преимущества:

  • Нет необходимости в каком-то рантайме на сервере: для работы достаточно обычного веб-сервера.
  • Высокая производительность: так как на сервере и клиенте практически нет никакой логики, скорость работы сайта становится максимально быстрой.
  • Полный контроль над всем что есть на сайте.
  • Простота создания шаблона.

Но есть и недостатки, например, порог входа достаточно высокий, потому что для создания сайта требуется иметь некоторые технические навыки, вроде HTML, CSS, JS.

Как проходил процесс переноса🔗︎

Посты🔗︎

Постов на моём старом сайте было не много - чуть больше сотни, все посты были отформатированы обычным HTML. Я мог просто перенести весь текст вместе с HTML разметкой, но решил переносить каждую статью вручную и переформатировать из HTML в Markdown руками. Благодаря этому я нашёл и исправил несколько ошибок в форматировании некоторых статей.

Выбор генератора🔗︎

Немного поискав подходящие генераторы, я нашёл 2 хороших варианта - Hugo и Zola, поверхностно изучив оба решения мой выбор пал на Zola - он функционально отстаёт от Hugo, но проще в использовании. Для моих задач функционала Zola достаточно.

Дизайн🔗︎

Задизайнил шаблон самостоятельно, немного подсматривая за существующими аналогичными сайтами. Потом сверстал - на современном CSS с переменными и вложенными стилями это делать одно удовольствие. Для текста использовал шрифт Roboto, для кода Consolas. Ещё для иконок добавил Font Awesome, но сейчас понял, что я использую всего 5 иконок, поэтому правильнее будет заместо этого шрифта использовать обычный SVG.

Ещё, оказалось, что шрифт Consolas по умолчанию есть только на Windows, на Android его нет, из-за чего код на Android'е выглядел ужасно. Пришлось тоже добавить этот шрифт на сайт.

CDN сервисы с шрифтами я не использую из-за возможных падений этих сервисов и из-за возможных блокировок. Всё это на моём опыте уже случалось.

Поиск🔗︎

Zola не реализует поиск по сайту самостоятельно, но предоставляет возможность генерировать данные для поиска для библиотек elasticlunr и fuse. Посмотрев как на существующих сайтах работает поиск с elasticlunr я остановился на fuse, тем более я его уже использовал в другом проекте.

Zola создаёт файл, который записывает данные для поиска в window.searchIndex. Скорость работы Fuse на столько большая, что можно не бояться и обновлять результаты поиска без всяких debounce.

Принцип работы реализованного мной поиска:

  • При нажатии на кнопку поиска открывается модальное окно и создаётся инстанс Fuse:
    if (fuse === null) {
      fuse = new Fuse(searchIndex, {
        includeScore: true,
        ignoreLocation: true,
        ignoreFieldNorm: true,
        keys: ['title', 'body'],
      });
    }
    
    При повторном открытии окна новый инстанс не создаётся, используется старый.
  • При вводе, в событии input, удаляются старые результаты поиска, выполняется поиск через Fuse:
    // удаляем старые результаты поиска
    searchResultEl.innerHTML = '';
    
    // ищем
    const inputElement = input.target as HTMLInputElement;
    const searchText = inputElement.value;
    const result = fuse.search(searchText);
    
    // переключаем отображения блока с результатами
    searchResultEl.style.display = result.length === 0 ? 'none' : 'flex';
    
    // создаём результаты
    result.forEach((item) => {
      // не отображаем неподходящие результаты
      if (item.score === undefined || item.score > 0.5) {
        return;
      }
    
      // создаём HTML элемент с резульатом поиска
      const el = createResultElement(item);
    
      searchResultEl.insertAdjacentElement('beforeend', el)
    });
    
  • Функция создания HTML элемента с результатом поиска:
    const createResultElement = (result: FuseResult<SearchIndexItem>) => {
      // ссылка
      const el = document.createElement('a');
      el.classList.add('search-results__item');
      el.href = result.item.url;
    
      // заголовок
      const titleEl = document.createElement('div');
      titleEl.classList.add('search-item__title');
      titleEl.innerHTML = result.item.title;
    
      // начало поста
      const textEl = document.createElement('div');
      textEl.classList.add('search-item__text');
      textEl.innerHTML = result.item.body.slice(0, 100);
    
      // формируем элемент
      el.insertAdjacentElement('afterbegin', textEl);
      el.insertAdjacentElement('afterbegin', titleEl);
    
      return el;
    }
    

Подсветка синтаксиса🔗︎

Мне очень понравилась встроенная в Zola подсветка синтаксиса, с шрифтом Consolas она выглядит шикарно. Zola для подсветки использует формат редактора Sublime Text sublime-syntax. Поэтому, достаточно найти такой файл для своего языка, добавить в каталог syntaxes и пересобрать сайт, всё подключится автоматически. Подсветка синтаксиса происходит на этапе компиляции, поэтому можно добавлять сколько угодно вариантов языков без какого-либо влияния на производительность конечного сайта.

Для некоторых нужных мне языков синтаксиса не оказалось, поэтому пришлось сделать свои:

Ещё я добавил:

  • phpp - это обычный PHP, стандартная подсветка PHP у меня, почему-то, не работала.
  • console - для подсветки консольных команд. Это просто синтаксис bash, но без использования # в качестве комментария.
  • nginx - подсветка конфигов nginx специально для этой статьи (ссылка на sublime-syntax).

Сборка🔗︎

Сначала я написал всё на JavaScript и CSS, но в Zola не оказалось функционала для минификации чего-то, отличного от HTML. Поэтому я решил добавить этап сборки для фронтенд кода и стилей. Это позволило переписать поиск на TypeScript и оставить в итоговом бандле только используемые стили и файлы шрифтов.

В качестве пакетного менеджера я использую Bun - он очень быстрый, а для сборки я использовал esbuild, он тоже быстрый, плюс включает в себя кучу функционала для настройки процесса. Скрипт для запуска сборки в package.json выглядит так:

"scripts": {
  "build:frontend": "bun run esbuild.ts --mode=production",
  "build:zola": "zola build",
  "build": "npm-run-all --silent \"build:frontend\" \"build:zola\"",
  "dev:frontend": "bun run esbuild.ts --watch",
  "dev:zola": "zola serve",
  "dev": "npm-run-all --parallel \"dev:frontend\" \"dev:zola\""
}

npm-run-all нужен для управления очерёдностью запуска сборки фронтенда и запуска тестового Zola сервера.

esbuild.ts выглядит достаточно стандартно, getArgs я скопировал из другого проекта:

esbuild.ts
import { parseArgs } from 'node:util';
import esbuild from 'esbuild/lib/main';
import path from 'node:path';

const getArgs = (argv: string[]) => {
  const { values: args } = parseArgs({
    args: argv,
    options: {
      mode: {
        type: 'string',
      },
      watch: {
        type: 'boolean',
      },
    },
    strict: true,
    allowPositionals: true,
  });

  if (args.mode !== undefined && args.mode !== 'production' && args.mode !== 'development') {
    throw new Error('Invalid mode, only "production" or "development" is valid');
  }

  return args as {
    mode?: 'production' | 'development';
    watch?: boolean;
  };
};

const args = getArgs(process.argv);

const mode = args.mode ?? 'development';
const watch = args.watch ?? false;

esbuild.context({
  bundle: true,
  platform: 'browser',
  minify: mode === 'production',
  define: {
    'process.env.NODE_ENV': `"${mode}"`,
  },
  target: 'es2020',
  tsconfig: path.resolve(__dirname, './tsconfig.json'),
  entryPoints: [
    path.resolve(__dirname, './frontend/css/styles.css'),
    path.resolve(__dirname, './frontend/src/index.ts'),
  ],
  loader: {
    '.woff2': 'file',
    '.ttf': 'file',
  },
  legalComments: 'none',
  outdir: path.resolve(__dirname, './static/build/'),
  plugins: [
    {
      name: 'on build message',
      setup: (bld) => {
        bld.onEnd((result) => {
          if (result.errors.length !== 0) {
            console.error(`Build \x1b[31mfailed\x1b[0m`);
          } else {
            console.log(`Build \x1b[32msucceeded\x1b[0m`);
          }
        });
      },
    },
  ],
}).then((ctx) => {
  if (watch) {
    ctx.watch();
  } else {
    ctx.rebuild();
    ctx.dispose();
  }
});

Хостинг🔗︎

Раньше сайт работал на веб-хостинге - это такой хостинг, который не даёт полный доступ к самой системе, а даёт только возможность загрузить свой PHP+MySQL сайт. Сейчас я перенёс его на обычный VPS, поэтому настройка веб-сервера перешла на меня - это создаёт некоторые неудобства, но даёт полный контроль над сайтом.

Для веб-сервера я выбрал Angie - это форк Nginx от его бывших разработчиков. Он полностью совмести с Nginx и имеет некоторы преимущества. Плюс в Angie из коробки поддерживается автоматическая выдача SSL сертификатов - это очень удобно.

Файл конфигурации для сайта ziggi.org выглядит так:

http {
    acme_client ziggi_org https://acme-v02.api.letsencrypt.org/directory;

    # Redirect HTTP to HTTPS
    server {
        listen 80;
        listen [::]:80;
        server_name ziggi.org;

        if ($host = ziggi.org) {
            return 301 https://$host$request_uri;
        }

        return 404;
    }

    server {
        # For older versions of nginx appened http2 to the listen line after ssl and remove `http2 on`
        listen 443 ssl;
        listen [::]:443 ssl;
        http2 on;
        server_name ziggi.org;

        acme ziggi_org;

        ssl_certificate $acme_cert_ziggi_org;
        ssl_certificate_key $acme_cert_key_ziggi_org;

        # compression
        gzip            on;
        gzip_min_length 1000;
        gzip_proxied    expired no-cache no-store private auth;
        gzip_types      text/plain application/xml;

        location / {
          expires    8h;
          add_header Cache-Control private;

          root /opt/ziggi.org/;
        }
    }
}

Старые посты располагались по адресу ziggi.org/название_поста, на новом сайте формат поменялся на ziggi.org/posts/название_поста, поэтому потребовалось настроить редирект со старого адреса на новый, делается это просто, вот пример одного такого редиректа:

rewrite ^/uinfo-v2-3 /posts/uinfo-v2-3 permanent;

Итог🔗︎

Итогом переноса стала мгновенная загрузка сайта и очень быстрый поиск. Появилась красивая подсветка синтаксиса, хорошие шрифты и никакого лишнего кода и стилей в итоговом бандле. Упростился процесс деплоя и я перестал бояться потери контента, так как сайт хранится в приватном репозитории GitHub.

В общем, если вы - разработчик, то статическая генерация сайтов the only way to go.