Как я написал движок для блога на GO
Изначально у меня был небольшой сайт-визитка, куда я выкладывал ссылки на свои выступления, статьи и прочее. Но мне это было не очень удобно, потому что приходилось каждый раз вручную менять html — не самый любимый мой формат, ведь для статей я предпочитаю markdown. В целом приходилось делать это не слишком часто, поэтому я не шевелился по этому поводу.
Однако недавно я начал изучать Go, и для практики я решил сделать движок для статического сайта. Мои требования были очень просты:
- Вёрстка в markdown.
- Возможность указывать мета-теги.
- Прикрепление файлов.
- Высокая скорость.
- Отсутствие базы данных.
Все требования довольно стандартные, но на последнем мы остановимся. Подробнее.
Как создать блог без базы данных
Этой идеей я вдохновился у Доки, потому что у них все статьи хранятся просто в формате .md
в отдельном репозитории. И когда в репозиторий мерджится новая ветка, запускается пайплайн, который парсит файлы и генерирует на их основе html.
Это позволяет иметь статический сайт, но не обновлять его всё время руками. А ещё не нужно раскошеливаться на дорогой сервер — можно просто кинуть готовые файлы на самый дешёвый хостинг.
Читайте также: Как я настроил CI/CD для блога на Go.
Так как все статьи у нас будут обычными файлами в markdown, у нас не так много технических требований, а сам алгоритм простой:
- Сканируем все
.md
файлы в определённой директории (рекурсивно). - Парсим их из markdown в html.
- Парсим мета-теги.
- Формируем итоговый документ из тела статьи, мета-тегов и html-шаблона.
- Сохраняем результат в финальную директорию.
Выглядит довольно просто, давайте приступать.
Пишем блог на Go
Находим все .md
файлы
Пока что мы просто находим все нужные нам файлы. При этом есть пара важных моментов:
- Нас интересуют только
.md
. Всё остальное мы отфильтровываем. - Также нужно отфильтровать все скрытые файлы и директории. Иначе у нас в выдачу попадёт
.git
и всё содержимое. А может и ещё что поинтереснее.
Я набросал вот такую функцию:
1func ScanAllFilepaths(root string) ([]string, error) {
2 paths := []string{}
3
4 entries, err := os.ReadDir(root)
5
6 if err != nil {
7 return []string{}, err
8 }
9
10 for _, entry := range entries {
11 if entry.Name()[0] == '.' {
12 continue
13 }
14 if entry.IsDir() {
15 var p, err = ScanAllFilepaths(root + "/" + entry.Name())
16
17 if err != nil {
18 return []string{}, err
19 }
20
21 paths = append(paths, p...)
22 continue
23 }
24
25 s := strings.Split(entry.Name(), ".")
26
27 if s[len(s)-1] != "md" {
28 continue
29 }
30
31 paths = append(paths, root+"/"+entry.Name())
32 }
33
34 return paths, nil
35}
Нам тут даже не важна какая-то скорость, потому что мы делаем это один раз на этапе сборки. Оптимизировать всё это будем, когда статей станет много, а для сборки прикрутится CI/CD. Сейчас же мне важно поскорее запустить всё это.
Парсим markdown на Go
Несмотря на то, что файлы мы уже нашли, конвертировать их в html пока рано. Для начала вспомним, но нам нужен не только текст, но и мета-информация: заголовок, описание и ключевые слова. Я решил не городить несколько файлов, а хранить эту информацию в файле со статьёй. Выглядит оно вот так:
1\---
2title: Что такое larana
3keywords: larana, gorana, ssr
4description: Революционный фреймворк
5date: 2025-01-01
6\---
7
8# Что такое Ларана?
То есть вначале файла лежит небольшой блок, который парсится в хэшмапу, а затем удаляется:
1func ParsePageInfo(f []byte) (Page, error) {
2 meta := NewMetaMap(nil)
3
4 text := string(f)
5
6 lines := []string{}
7
8 closed := false
9
10 for i, v := range strings.Split(text, "\n") {
11 if i == 0 && v != "---" {
12 return Page{
13 Content: f,
14 Meta: meta,
15 }, nil
16 }
17
18 if i == 0 {
19 continue
20 }
21
22 if i != 0 && v == "---" {
23 closed = true
24 continue
25 }
26
27 if closed {
28 lines = append(lines, v)
29 continue
30 }
31
32 s := strings.Split(v, ":")
33
34 if len(s) < 2 {
35 return Page{}, errors.New("meta string should have key and value")
36 }
37
38 if len(s) == 2 {
39 meta[s[0]] = strings.Trim(s[1], " ")
40 continue
41 }
42
43 meta[s[0]] = strings.Trim(strings.Join(s[1:], ":"), " ")
44 }
45
46 return Page{
47 Content: []byte(strings.Join(lines, "\n")),
48 Meta: meta,
49 }, nil
50}
Я не стал ничего выдумывать для парсинга .md
, а просто взял готовую библиотеку gomarkdown/markdown. Особо останавливаться на этом не будем.
Формируем шаблон
На самом деле в go есть встроенный пакет template/html
, с помощью которого можно форматировать html-шаблоны. Однако для моих нужд он избыточен, поэтому я просто использовал strings.Replace()
:
1func FormatTemplate(tmplt string, f Page) string {
2 text := tmplt
3
4 for key, value := range f.Meta {
5 text = strings.Replace(text, "%"+key+"%", value, 1)
6 }
7
8 html := mdparcer.MdToHTML(f.Content)
9 text = strings.Replace(text, "%CONTENT%", string(html), 1)
10
11 return text
12}
SSG
Остался последний этап, который по умному называется SSG — static site generation. По сути мы просто сохраняем уже отформатированные шаблоны в нужную директорию. И туда же копируем все изначально статические файлы.
1func exportPages() error {
2 root := configs.ContentDirectory
3
4 fmt.Println("exporting pages...")
5
6 paths, err := pages.ScanAllFilepaths(root)
7
8 if err != nil {
9 return err
10 }
11
12 tmp, err := pages.ReadTemplateFile()
13
14 if err != nil {
15 return err
16 }
17
18 for _, v := range paths {
19 page, err := pages.ReadPageFile(v)
20
21 if err != nil {
22 return err
23 }
24
25 formatted := pages.FormatTemplate(tmp, page)
26
27 newPath := strings.Replace(page.Filepath, configs.ContentDirectory, distPath, 1)
28 newPath = strings.Replace(newPath, ".md", ".html", 1)
29
30 s := strings.Split(newPath, "/")
31
32 dirPath := strings.Join(s[0:len(s)-1], "/")
33
34 os.MkdirAll(dirPath, 0777)
35
36 err = os.WriteFile(newPath, []byte(formatted), 0666)
37
38 if err != nil {
39 return err
40 }
41 }
42
43 return nil
44}
SSG на Go готов
Дальше остаётся только собрать все функции в одном месте и получится готовый блог. Вы, кстати, сейчас читаете эту статью в этом самом блоге. Суммарно получилось всего 1267
строк кода и только 556
из них было на go. Кроме самого SSG сюда включаются ещё и подобие SSR, но это скорее дев-сборка, потому что там используются все те же алгоритмы, что и для статической генерации.
Например, если мы хотим почитать одну конкретную статью, то у нас заново считается файл статьи и файл шаблона, заново всё будет распаршено и отформатировано. А если мы хотим открыть такую мелочь как список статей, то спарсятся вообще все статьи. То есть если мы хотим запустить этот сервер для раздачи динамического контента, то нужно думать про кеширование.
На самом деле термин SSR (server side rendering) некорректный. Никакого рендеринга не происходит — только генерация макета. Я бы переименовал его в SSMG — server side markup generation.
Подробнее в моём докладе: «Larana: Настоящий SSR».
Сделал ли я что-то уникальное? Нет. Есть ли готовые решения получше? Определённо. Но это мой велосипед и я на нём катаюсь.
Достоинства блога на Go
Постепенно достоинства я описывал в самой статье, но давайте пройдёмся ещё раз:
- Максимальная скорость — никакого оверхеда по приложению, просто отдаём статику.
- Хостим где угодно — хоть на самом дешёвом хостинге, хоть на github pages.
- База данных это обычный репозиторий — есть история, бэкапы и лёгкий доступ
Недостатки блога на Go
Я, конечно же, опишу недостатки своей реализации. Возможно, есть какие-то другие движки, которые уже с этим справились. Но у меня нет задачи найти лучшее решение, моя задача — попрактиковаться с новой технологией.
- Плохой CLS из-за картинок
CLS или cumulative layout shift — то, насколько дёргается ваш сайт при подргузке новых данных. Если изменение большое, то ранжирование у сайта будет так себе, потому что это бесит пользователей. Представьте, что вы навели курсор на кнопку «Войти», а она в этот момент сдвинулась вниз, потому что сверху подгрузилась реклама. Бесит.
В моём блоге эта беда есть с картинками:
В принципе, ситуация решаемая, даже несколькими способами:
- Вставлять картинки с помощью синтаксиса html, а не markdown.
- Использовать картинки одинакового размера, и указывать дефолтный размер в css.
- Придумать ещё какой-нибудь костыль.
Вероятно, я решу это когда-нибудь, но пока мне лень.
- Нет сбора статистики
Недостаток это с натяжкой и то не для всех. Я, например, в последнее время всё больше думаю о приватности, поэтому не хочу делиться своими персональными данными и оставлять цифровой след. И мой блог предоставляет читателям эту приватность. Так как сайт полностью статичный и без JavaScript, я не собираю куки, не пишу логи, не записываю ваши айпишники и так далее.
Просто читайте и наслаждайтесь.
А если хотите, чтобы ещё и я насладился, можете задонатить мне на бусти или patreon.
- Нет лайков и комментариев
Опять же, сайт статичный, поэтому я не могу собрать у вас обратную связь напрямую. Возможно, где-то я допустил ошибку, а вы хотите её исправить. В таком случае вам остаётся только написать мне на почту или в телеграм. Контакты в подвале страницы.
- Нет наработок по SEO
Современные движки вроде Wordpress предоставляют всякие готовые функции для работы с поисковой оптимизацией. У меня же есть возможность только писать интересные тексты и надеяться, что мне повезёт и меня покажут в телевизоре поисковой выдаче.
Кроме текстов у меня есть разве что мета-теги, но их количество ограничено. То есть мне нужно дорабатывать код, чтобы добавить возможность социальным сетям подцеплять нужную картинку и описание для каждой страницы.
- Не самый удобный роутинг
Так как сайт статичный, все файлы в нём формата .html
. И чтобы зайти на какую-то страницу, нужно вводить адрес вида /some-page.html
, а хотелось бы просто /some-page
. Это, конечно, можно поправить в настройках nginx, но это дополнительные действия. К счастью, проблема решается, если просто создать файл index.html
в папке /some-page
.
Хотя, конечно же, хотелось бы не городить столько директорий. Но, что уж поделать — компромисс.
Если что, фикс буквально в одну строку в nginx, но тогда повышается шанс, что на каком-то самом дешёвом хостинге или том же github pages чего-то не заведётся:
1location / {
2 try_files $uri $uri.html $uri/ =404;
3}
- Невозможность поиска
Любой поиск возможен только вручную — заходите на страницу со списком статей и ищете, что вам нужно. Максимум, что вам доступно, это использовать встроенный поиск в браузере.
- Нет подсветки синтаксиса
В основном мой контент так или иначе связан с программированием, но добавить подсветку синтаксиса я не хочу — это сильно увеличит поток трафика, потому что код форматируется с помощью html. А мне бы всё-таки хотелось сохранить легковесность страниц. По большей части это связано с ограничениями протокола. Максимально быстро может прилететь только пакет до 14kb. А если страница больше, то скорость падает в разы.
Надо ли говорить, что у меня размер текста может быть больше, не то что лишний код на подсветку.
Конечно, это можно решать на клиентской стороне с помощью JS, но в чём тогда смысл?
UPD. Всё-таки подсветку синтаксиса я добавил.
Заключение
В заключение хочу отметить, что мне было весело этим заниматься.
А ещё, что дальше в планах несколько доработок:
- Заменить топорную работу со строками на работу с шаблонами.
- Внедрить генерацию sitemap.xml. Скорее всего использую готовые наработки вроде go-sitemap-generator.
- Сделать каталог более презентабельным.
- Добавить пагинацию.
Со временем я это сделаю, но старт уже неплохой.
Репозиторий проекта: https://github.com/e-kucheriavyi/gossrng
Подписывайтесь на мою телегу: @frontend_director