malikov.tech

Зеркало блога

Cover Image for Зеркало блога

Как и писал ранее задумалось мне сделать зеркало бложега моего на своем домене. А то пропадает домен и вообще, возможности писать большие лонгриды хочется иногда. Да и честно говоря все мои мысли уже давно сводятся к тому, что площадка должна быть собственная для исходного контента, а с неё уже куда угодно дальше транслировать копии и вариации.

Далее лонгрид о том, как сделать по шагам за три копейки, простой и надежный статически генерируемый сайт на современном стеке, но без лишних заморочек.


Начать нужно с концепции.

В моем случае она заключается в том, что я хочу иметь на своем домене простой блог, где есть возможность размещать посты больше 4086 символов. Исходник поста должен представлять из себя markdown файл ибо не содержит ничего лишнего и для текстового блога идеален. Ссылка на пост должна поддерживать микроразметку при шаринге и вообще быть индексируемой поисковиками, а значит никакого SPA. Но при этом я хотел бы пользоваться современными фронтенд инструментами. Такими как React и Tailwind. Мне они очень понравились и кажется они должны лечь в основу блога. Опять таки визуальный стиль я хотел бы сделать максимально простым и не заморачиваться с сложными шаблонами готовых headless cms, поэтому в идеале что бы все было самописным и мне подконтрольным. Бекенда у этого сайта быть не должно (пока что) поэтому комментарии остаются за исходной площадкой в виде телеграмма. Для работы сайту должен быть нужен только linux сервер с nginx и всё. Никаких nodejs, python, go, java и прочего не должно быть необходимо для работы. Генерация же должна быть быстрой и по возможностью легковесной.

ОК. Посмотрим на то, что есть на рынке из готовых инструментов.

Прочитав несколько подборок в интернетах, на тему что сейчас популярно и вообще используется для статичной сайтогенерации, получился такой список:

Название Фичи Минусы
Astro Build Быстрый
Есть поддержка всего, что хотелось бы от современного фронтенда
Вещь в себе, не совсем в стеке
Кажется требует изучения больше чем хотелось бы
Next.js Самый популярный
В целом кажется нет ограничений
Есть куча готовых туториалов и обкашленных вопросиков
Не очень быстр
Инструмент широкого назначения
Gatsby Показалось, что строится вокруг идеи использования GraphQL Оверхедный для моих задач
VuePress Простая сайтогенерилка на vue Не тот стек
VitePress Тоже самое, что VuePress но использует другую систему сборки Не тот стек
HUGO Тут начинаются Go движения, интересный проект, но не соответствует концепции Не тот стек
Nuxt.js По сути это тотже next только для vue Не тот стек
Eleventy Если astro быстрый, то этот позиционируется как еще более шустрая штуковина по рендерингу маркдаун файликов
Выглядит интересно
Нужно погружаться и изучать, немного вещь в себе и не совсем в выбранном стеке
Jekyll Тут подъезжают рубисты Не тот стек
Docusaurus Вообще-то выглядит очень интересным решением, я бы рассматривал его в качестве основы Менее распространен, чем next
Gridsome Jamstack фреймворк поверх vue Не тот стек
Harp.js В общем-то обычный сайтогенератор Не совсем в стеке
React static Прикольная штука для реакта, можно было бы рассмотреть Есть проблемы с выходом новой версии, не понятна судьба проекта
JigSaw Ну и как же без php, шутка использующая Laravel Blade под капотом Не тот стек

На выходе у меня остались три фаворита:

  1. Nextjs - простой, удовлетворяет мои потребности, вообще не требует какого-то чтения документации, есть куча готовых рецептов
  2. Docusaurus - если не понравится опыт работы с nextjs этот проект будет следующим для изучения и опробирования работы с ним
  3. React static - если все остальное не подойдет, то надо бы внимательно изучить вопрос судьбы проекта и что там с ним дальше можно делать

Если говорить откровенно, то Astro Build & Eleventy меня заинтересовали, но из-за не прямого совпадения с концепцией выбранного стека вылетели из фаворитов. Точно на них буду смотреть, но в рамках какого-то другого r&d.

Начинать надо с самого понятного, простого и популярного. Поэтому даже без чтения подборок и документации каждого из проектов выше было очевидно, что пробовать я начну с nextjs. Но прежде чем начать, что с ним делать, нужно вытащить архив из телеграмма и превратить его в исходники.

Подготовка постов

Задача разовая поэтому как её решать каждый выберет сам. Мне проще всего решить было её через:

  • Выгрузку из приложения телеграмма архива канала со всеми вложениями в json формате
  • С помощью простого скрипта на go превратить json в набор md файликов

После скачивания и распаковки архива канала мы получим примерно следующий набор файлов:

➜ ChatExport_2023-04-13 ls -1
files/
photos/
result.json
round_video_messages/

Ну и собственно сам скрипт выглядит так, но повторюсь, что этот шаг можно решить любым удобным способом.

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"os"
	"strings"
)

type TextEntities struct {
	Text string `json:"text"`
}

type Message struct {
	Id           int            `json:"id"`
	Type         string         `json:"type"`
	Date         string         `json:"date"`
	TextEntities []TextEntities `json:"text_entities"`
	Photo        string         `json:"photo"`
}

type Exported struct {
	Messages []Message `json:"messages"`
}

func main() {
	data, err := os.ReadFile("result.json")
	if err != nil {
		log.Fatal(err)
	}
	messages := Exported{}
	err = json.Unmarshal(data, &messages)
	if err != nil {
		log.Fatal(err)
	}

	err = os.Mkdir("posts", 0755)
	if err != nil {
		log.Fatal(err)
	}

	for _, v := range messages.Messages {
		title := ""
		excerpt := ""
		coverImage := ""
		date := ""

		// склеиваем телеграммовскую разбивку поста в одну строку
		body := ""
		for _, p := range v.TextEntities {
			body = body + p.Text
		}

		// в моем случае отрезаем посты не проходящие условие наличия более 1 строчки.
		if len(v.TextEntities) > 1 {
			line := strings.Split(body, "\n")
			if len(line) > 1 {
				if line[1] != strings.TrimSpace("") {
					excerpt = strings.TrimSpace(line[1])
				} else {
					if len(line) > 2 {
						excerpt = strings.TrimSpace(line[2])
					}
				}
			}
			title = strings.TrimSpace(line[0])
		}

		if v.Photo != "" {
			coverImage = "/assets/" + v.Photo
		}

		// костыльное приведение к ISO формату
		date = v.Date + ".000Z"

		// этот кусок специфичен выбранному стеку и инструменту в котором я дальше буду парсить md файлы
		post := fmt.Sprintf(`---
title: '%s'
excerpt: '%s'
date: '%s'
coverImage: '%s'
---

`, title, excerpt, date, coverImage)
		post = post + body
		err = os.WriteFile(fmt.Sprintf("posts/%d.md", v.Id), []byte(post), 0777)
		if err != nil {
			log.Fatal(err)
		}
	}
}

Тем не менее кладем эту радость в корень с распакованными файлами архива канала и запускаем через go run main.go ну или можете его собрать или переписать, или как хотите в общем.

Результат на этом шаге папка ./posts содержащая в себе большое кол-во md файлов. Мне было не лень их все отсмотреть, поэтому пустые или не корректно собранные я откорректировал руками. Но в целом нет предела совершенству и можно было бы доработать скрипт. Но мне было лень.

➜ ChatExport_2023-04-13 ls -1 ./posts 
1.md
10.md
100.md
101.md
...

Разработка макета и рендеринг постов

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

Т.к. nextjs штука популярная у него есть куча примеров разного рода конфигураций его использования. А в крупную клетку задачка моя тоже не уникальна ни разу, поэтому и макет готовый нашелся очень быстро - https://next-blog-starter.vercel.app.

Собственно в чем удача нам тут улыбнулась, данный пример решает ровно ту задачу, которую мне требуется победить. И из примера исходного md файла становится понятно, что нужно добавить в мои исходники постов, что бы они завелись из коробки. (в скрипте на go выше я это учёл)

Конечно меня не устраивает совсем макет из коробки, поэтому я вырезал некоторые ненужности, а именно блок с авторством и изменил немного верстку постов на ту, как мне бы этого хотелось. Но фактически сразу мы получаем результат.

Сборка проекта по умолчанию через npm run build генерирует статический сайт, который потенциально нужно отдавать через nodejs я так подозреваю.

Директория с собранным проектом выглядит примерно так:

➜  example.com ls -1 .next 
cache/
static/
  chunks/
  css/
  G8DfmrN_jxluFTZzGIZ9M/
server/
	chunks/
  pages/
    posts/
    	7.html
      7.json
      8.html
      8.json
      ...
  	404.html
    500.html
    _app.js
    _app.js.nft.json
    _document.js
    _document.js.nft.json
    _error.js
    _error.js.nft.json
    index.html
    index.js
    index.js.nft.json
    index.json
	font-manifest.json
	middleware-build-manifest.js
	middleware-manifest.json
	middleware-react-loadable-manifest.js
	next-font-manifest.js
	next-font-manifest.json
	pages-manifest.json
	webpack-runtime.js
...

Из неё видно, что для работы нам нужна папка со статикой и папка server/pages.

Тут мне стало интересно решить задачку без погружения в документацию и без перенастройки сборки (хотя потом, я конечно обязательно это сделаю), поэтому загружаем всё что нам нужно на сервер и начинаем крутить nginx конфиг так, чтоб в консоли браузера не было ошибок и сайт отображался корректно.

В конечном счете получился такой конфиг:

server {
	server_name example.com;
  listen 80;
	index index.html;
	root /var/www/example.com;
	location /_next/image {
  	if ($request_uri ~ "\/_next\/image\?url=%2Fassets%2Fphotos%2F(.*)&w(.*)$") {
			return 301 /public/assets/photos/$1;
		}

		return 404;
	}

  location /_next/data {
    if ($uri ~ "_next\/data\/(.+)\/posts\/(\d+).json") {
        return 301 /jsons/$2;
    }

		if ($uri ~ "_next\/data\/(.+)\/index.json") {
        return 301 /index.json;
    }

    return 404;
  }

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

  location /jsons {
    try_files $uri.json $uri/ =404;
  }

	location / {
		try_files $uri $uri/ =404;
	}
}

Важно повториться - этот конфиг и вообще весь этот путь всего лишь результат моего интереса, сколько нужно сделать костылей, чтоб это работал без nodejs и без допила сборки nextjs проекта

Но что бы оно заработало корректно сделать нужно еще несколько костылей с ФС. Дабы не расписывать весь путь и мысли, конечный деплой получился на этом этапе таким:

npm run build
ssh user@example.com "rm -rf ~/example.com"
scp -r ~/example.com/.next/server user@example.com:~/example.com
scp -r ~/example.com/public user@example.com:~/example.com/pages/
ssh user@example.com "mkdir -p ~/malikov.tech/pages/_next/"
scp -r ~/example.com/.next/static user@example.com:~/example.com/pages/_next/
ssh user@example.com "ln -s ~/example.com/pages/public/assets ~/example.com/pages/assets"
ssh user@example.com "ln -s ~/example.com/pages/public/favicon ~/example.com/pages/favicon"
ssh user@example.com "ln -s ~/example.com/pages/posts ~/example.com/pages/jsons"

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

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

Также стало ясно, что в телеграмм нужно настраивать шаблоны для Instant View чтобы посты открывались как лонгриды телеграфа или медиума. Честно говоря я думал это делается микроразметкой, но оказалось, что нет и надо это делать самому в лк телеги.

Финальным аккордом пишется простой компонент на реакте, который подключает комментарии с помощью виджета телеграмма в шаблон показа поста.

import React, {useEffect, useRef} from "react";

export const COMMENTS_APP_SCRIPT = "https://telegram.org/js/telegram-widget.js?22";

export interface TelegramCommentsProps {
    commentsNumber: number;
    discussion: string;
    dataColor: string;
    darkColor: string;
}

const TGComments = ({
                        commentsNumber = 5,
                        discussion,
                        dataColor,
                        darkColor,
                    }: TelegramCommentsProps) => {
    const placeholderRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
        const script = document.createElement("script");
        script.src = COMMENTS_APP_SCRIPT;
        script.async = true;
        script.setAttribute("data-comments-limit", commentsNumber as unknown as string);
        script.setAttribute("data-telegram-discussion", discussion);
        script.setAttribute("data-color", dataColor);
        script.setAttribute("data-dark-color", darkColor);

        if (placeholderRef.current) {
            placeholderRef.current.appendChild(script);
        }
    }, []);

    if (!discussion) {
        return null;
    }

    return (
        <div data-testid="rtc-container" className={''}>
            <div
                data-testid="rtc-wrapper"
                className={''}
                ref={placeholderRef}
            />
        </div>
    );
};

export default TGComments;

За основу был взят вот этот компонент

Ну и в общем-то всё.

Про всякую мишуру типа фавиконок и вырезания "демо" текста внутри шаблона умолчу ибо само собой разумеющееся.

Итого весь этот процесс занял у меня где-то 4 часа и больше всего времени оторвало конечно ковыряние конфига nginx. Надо было прочитать документацию и поправить просто сборку. Но это я сделаю позже и напишу как сделать правильно. А пока результат достигнут и я доволен на этом этапе.

Это точно не финальная версия и мне нужно еще оптимизировать процесс публикации постов, деплой всего этого дела из github, синхронизация новых постов с репозиторием в гитхабе и возможно смена nextjs на что-то еще в рамках r&d и общего развития.

С пятничкой!