Skip to content

О статических генераторах сайтов

Задача статической генерации

Допустим мы хотим сгенерировать статический сайт. Технически любой сайт - это статический набор файлов, но в современном мире "статически сгенерированными сайтами" называют те, которые не управляются каким-либо js фреймворком. В самом простом случае это html+css без js вообще. В чем преимущества таких сайтов над сайтами, которые используют js-фреймворки:

  • статический набор файлов, для открытия не нужен ни сервер ни клиентские мощности для выполнения js
  • быстрее загружаются т.к. есть только запросы на страницу и на стили, вместо кучи запросов на чанки жс файлов, серверные запросы, и тд
  • (то, чем я никогда не занимался) проще индексируются поисковиками
  • вместо выполнения кода 100 раз у 100 пользователей, мы выполняем генерацию 1 раз на сервере, что сохранит больше тепла, выделяемого процессорами пользователей, и следовательно умрут меньше котиков

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

Статическая генерация на практике

Попробуем разобраться, как это все делать на практике. Из известных тулзов есть множество на js, которые классически требуют nodejs, npm, 100500 пакетов и билд все равно падает с непонятными ошибками, которые чинятся перебором букв в 100 конфигах проекта. Из относительно юзабельных опций есть такие:

  • docsify - умеет генерировать сайт из markdown, но не статически, а статическим js скриптом. Соответственно запустить этот скрипт один раз и получить html-ку с контентом просто так не получится.
  • hugo - умеет генерировать сайты из markdown, для кастомизации предлагается использовать шаблонизацию и куски html до и после самого контента. Как при этом управлять самой генерацией контента я так и не понял, видимо влиять на отображение контента предлагается только через стили css на элементы внутри div#content либо писать шаблоны прям в markdown. Также в целом тулинг(как по мне) переусложнен: тащатся какие то Go модули, git сабмодули, как-то это все непонятно как работает, что называется easy но не simple.

В итоге мы имеем следующую ситуацию:

  • Распространенным форматом не кода для написания документации является markdown
  • Тулинг неразвит или переусложнен(что свойственно js-фреймворкам). Хочется видеть тулинг простейший в виде подобном ssg generate -i input.md -o output.html и который следовательно можно комбинировать и композировать как угодно.
  • Существующий тулинг рассматривает генерируемый контент преимущественно как неструктурированный текст и соответственно широко использует шаблонизацию. Что странно, в свете того, что html - достаточно простой по структуре язык. Шаблонизация его вызывает такие же странные эмоции, как шаблонизация yaml-ов.

Опять про конфиги

Попробуем посмотреть в другую сторону статической генерации в поисках более удобных и понятных подходов (по крайней мере лично для меня). По поводу генерации структурированных данных я уже писал ранее. Кратко, суть была в том, что вместо использования шаблонизации для генерации yaml-ов и вместо придумывания ad-hoc программных конструктов внутри языка можно использовать собственно сам язык программирования, в котором все это уже есть и многое другое. В частности это было в приложении к jsonnet, но подходит и что угодно другое: Python, Javascript, Go, C, Haskell, самописные скриптовые языки, то есть все что угодно. Помимо явных control flow конструкций, мы получаем:

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

Сразу упомяну complexity clock - наблюдение, что конфиги проходят циклически 4 стадии

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

// вместо
config := []string{}
for i := 0; i < 10; i++ {
  config = append(config, strconv.Itoa(i))
}
return Config{Names: config}

// может быть достаточно
return Config{Names: []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}}
Идея тут не в этом конкретном примере, а в том, что синтаксис литералов структур в любом языке сам по себе вполне является языком конфигурации: json в JavaScript, ron в Rust и т.д. (увы не для всех языков придуманы названия для соответствующих языков литералов структур).

Get back to reality

Вернемся к статической генерации. Посмотрим на эту задачу с двух сторон.

  html
    ^
??magic??
    ^
 markdown

Сторона первая. HTML

Что мы хотим получить в итоге? Мы хотим получить документ следующего вида:

<html>
<head>
  <title>Hello</title>
</head>
<body>
  <h1>Hello world</h1>
</body>
</html>
Никогда не понимал, зачем по два раза писать название тегов, ну ладно. По сути, все что нам нужно - это структура вида
html
  head
    title
      = Hello
  body
    h1
      = Hello world
А это уже вполне описывается с помощью какого-нибудь yaml(json). Да, это изначально уже было описание в формате конфига xml, но xml это ужас, всегда хорошо уйти от него в сторону чего то более простого(тут не подразумевается yaml). Тогда, мы получаем примерно следующую структуру:
{
  "html": {
    "head": {
      "title": "Hello",
    },
    "body": {
      "h1": "Hello world"
    },
  },
}
Но это не очень хорошее представление по следующим причинам:

  • Ключи в словаре не имеют фиксированных порядок, а значит элементы в итоге будут разбросаны внутри блока как попало. Нам это абсолютно не подходит.
  • Ключ в пределах блока уникальны, а значит мы не можем, например, добавить второй h1 в body, это будет невалидный json.
  • Помимо тега и чайлдов у элементов также могут быть пропы: набор пар ключ-значение.

Вообще говоря, уже придуман формат который нам подойдет: каждый элемент это тройка ["tag", {props}, [children]/content]. Чтобы посмотреть на пример с пропами, добавим красный цвет на h1:

["html", {}, [
  ["head", {}, [
    ["title", {}, "Hello"],
  ],
  ["body", {}, [
    ["h1", {"style": "color: red;"}, "Hello world"],
  ],
]
Можно уже заметить повторяющийся паттерн: можно заменить некоторые элементы на функцию вида
const el = (tag, children...) => [tag, {}, children];

el("html",
  el("head", el("title", "Hello")),
  el("body", ["h1", {"style": "color: red;"}, "Hello world"]),
)
Можно также вынести отдельные функции для отдельных тегов
const html = (head, body) => el("html", el("head", head...), el("body", body...));
const title = (text) => el("title", text);
const h1 = (text, props?) => ["h1", props||{}, text];

html(
  [title("Hello")],
  [h1("Hello world", {"style": "color: red;"})],
)

По итогу, что нам нужно, чтобы написать html - это набор элементов: html, title, h1, div и т.д. и передавать им "свойства": пропы и чайлды. Можно придумать более сложные элементы, или как их принято называть "компоненты", например для поля формы мы хотим иметь лейбл и сам инпут:

const div = (children...) = el("div", children...);
const span = (text) => el("span", text);
const form = (children...) = el("form", children...);
const input = () => ["input", {type: "text"}];
const formInput = (label) => div(
  span(label),
  input(),
);

form(
  formInput("login"),
  formInput("password"),
)
даст что-то такое:
<form>
  <div><span>login</span><input type="text"></input></div>
  <div><span>password</span><input type="text"></input></div>
</form>
Заметим, что наши "компоненты" созданные из базовых элементов требуют те же пропы и чайлды, что и обычные элементы (в js фреймворках это называют пропсы и слоты), а значит со стороны нашего "конфига" они неотличимы от обычных элементов.

Сторона вторая. Markdown

Теперь рассмотрим, если так можно выразиться, "модель данных" markdown документа. Для примера возьмем такой документ:

# Hello world
## Heading2

This is `example` test with code conflicting with outside document:
'''go
func main()
'''
Я не профессиональный автор парсеров markdown, но по мне тут достаточно линейная структура:

  • заголовок h1
  • заголовок h2
  • текст
  • inline code
  • текст
  • codeblock

Можно также заметить, что как и в html для этих "элементов" есть начало и конец:

  • для заголовков # и ## это начало, перенос строки - конец
  • для inline code ` обозначает и начало и конец
  • для codeblock ```<lang> обозначает начало и ``` (стоит заметить проблему многострочных/сырых строк: внутри кодблока не получится вставить три символа ` (в примитивный inline code один тоже не получится, возможно когда нибудь я напишу об этой проблеме отдельно).

Теперь мы можем переписать этот же пример в json формате в виде [kind, content] (подсветка синтаксиса начинает сходить с ума):

[
  ["#", "Hello world"],
  ["##", "Heading2"],
  ["", "This is "],
  ["`", "example"],
  ["", " test with code conflicting with outside document:"],
  ["```go", "func main()"],
]

То же самое можно написать, назвав элементы по человечески: h1, h2, text, code и codeblock, а можно уменьшить количество базовых элементов, вынеся разницу в пропы:

[
  ["h", {level: 1}, "Hello world"],
  ["h", {level: 2}, "Heading2"],
  ["text", {}, "This is "],
  ["code", {inline: ""}, "example"],
  ["text", {}, " test with code conflicting with outside document:"],
  ["code", {block: "", lang: "go"}, "func main()"],
]
Здесь нам для каждого элемента нужны: тип элемента, набор пропов и содержимое элемента. Почти как в случае с html, только вместо чайлдов или контента у нас всегда текстовый контент. Может ли быть случай, когда в элемент надо вложить другие элементы? Конечно, примерами будут составные элементы, такие как списки и таблицы. Рассмотрим, например списки:
- 1
    - 1.1
    - 1.2
        - 1.2.1
- 2
    - 2.1
        - 2.1.1
        - 2.1.2
    - 2.2
        - 2.2.1
Этот список можно представить так [label, children...]:
["list",
  ["1", ["1.1"], ["1.2", "1.2.1"]],
  ["2", ["2.1", "2.1.1", "2.1.2"], ["2.2", "2.2.1"]],
]
Это позволяет программно строить списки, например Table of contents. Для этого нам достаточно взять заголовки и согласно их уровням составить список. Пропами списков при этом могут быть, например, обозначения: нумерованный ли список или нет, нумерован ли буквами или цифрами или римскими цифрами(для markdown впрочем это слабо контролируемо, можно только различить нумерованные списки от ненумерованных).

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

Сторона абстрактная. Теория всего

Рассматривая модели данных html и markdown мы пришли к тому, что все, что нам нужно для их "конфигурации" - это знать их набор базовых элементов, сами элементы при этом параметризуются пропами и чайлдами - другими элементами.

Если рассмотреть другие языки разметки, окажется, что они сводятся к тому же:

\begin{codeblock}
...
\end{codeblock}
    ```
    ...
    ```
<code>
...
</code>
::

  ...
----
...
----

В нашем же конфиге это будет codeblock("...").

Другой пример - картинки:

\includegraphics{image}
![](url)
<img src="url" />
.. image:: image_path
image::image_path[]

В нашем же конфиге это будет image("url").

То есть:

  1. Каждый язык разметки определяет
    • набор базовых элементов и их параметров
    • способ записи этих элементов и их параметров
    • способы композиции элементов
  2. Мы, используя скриптовый язык, имеем один базовый элемент вида element(props, children), который записывается всегда одинаково, композируется одинаково, но при этом может представлять как минимум то же, что и языки разметки.
  3. Скриптовый язык позволяет нам легко создавать новые элементы на основе старых, что усложнено или невозможно в языках разметки.

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

h = lambda level, text: "#"*level + " " + text
text = lambda text: text
code = lambda content: "`" + content + "`"
codeblock = lambda lang, content: "```" + lang + "\n" + content + "\n```"

"\n".join([
  h(1, "Hello world"),
  h(2, "Heading2"),
  text("This is ") + code("example") + text(" test with code:"),
  codeblock("go", "func main()"),
])

Далее, можем заметить, что мы то вообще то хотели сгенерировать некоторый документ, будь то html или markdown или не дай боже pdf, и при его написании не хотели бы думать о том, какие элементы существуют в итоговом языке разметки. Вместо этого мы можем определить свои элементы! В какую строку они будут рендериться? Пока непонятно, поэтому отложим этот вопрос, параметризуя функцию рендера:

const render = (h1, image, codeblock, text, join) => join(
  h1("Final answer to universe, life and anything"),
  image("sun.png"),
  text("Hello world! Finally answer has been found! It is:"),
  codeblock("42"),
);

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

render(
  header => `# ${header}`,
  image => `![](${image})`,
  codeblock => "```\n"+codeblock+"\n```",
  text => text,
  paragraphs => paragraphs.join("\n"),
)
так и, например, в html:
render(
  header => `<h1>${header}</h1>`,
  image => `<img src="/static/images/${image}">`,
  codeblock => `<code>${codeblock}</code>`,
  text => `<p>${text}</p>`,
  paragraphs => paragraphs.map(s => `<div>${s}</div>`).join(""), // перенос строки не обязателен
)

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

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

Описание системы

Еще раз повторюсь, документ - это функция, принимающая набор элементов, в терминах которого мы пишем сам документ.

Реализацию этих компонентов, можно назвать "адаптером", мы определяем каждый элемент в терминах языка разметки, в который рендерим документ.

// документ
const document = ({h1, image, codeblock, text, join}) => join(
  h1("Final answer to universe, life and anything"),
  image("sun.png"),
  text("Hello world! Finally answer has been found! It is:"),
  codeblock("42"),
);

// адаптер
const markdown_adapter = {
  // в виде лямбда полей
  h1: header => `# ${header}`,
  image: image => `![](${image})`,
  // или методов, не принципиально
  codeblock(codeblock) {return "```\n"+codeblock+"\n```"},
  text(text) {return text},
  join(paragraphs) {return paragraphs.join("\n")},
};

// рендер
document(markdown_adapter);

Отдельно стоит обсудить адаптеры. Все, для чего они нужны - это чтобы выразить элементы документа в терминах элементов определенного языка разметки. Как мы выяснили, все языки - это сами по себе некоторые наборы элементов, соответственно они могут быть определены заранее для упрощения написания адаптеров:

// определяется один раз и навсегда
const MD = {
  h1: header => `# ${header}`,
  image: (alt, image) => `![${alt}](${image})`,
  codeblock: (lang, codeblock) => "```" + lang + "\n"+codeblock+"\n```",
  text: text => text,
  a: (text, href) => `[${text}](${href})`,
};

// определяется для каждого документа/набора элементов
const markdown_adapter = {
  h1: MD.h1,
  image: image => MD.image("", image),
  codeblock: codeblock => MD.codeblock("", codeblock),
  text: MD.text,
  join(paragraphs) {return paragraphs.join("\n")},
};

Также можно расширять существующие адаптеры, используя что-то типа паттерна "декоратор", например, пусть нас устраивает MD как набор компонентов, почти полностью, но мы хотим, чтобы внешние ссылки были с дополнительным значком, а также хотим добавить пару кастомных элементов, например ссылку на спонсора и баджик:

const adapter = {
  ...MD,
  a: (text, href) => {
    if (href[0] !== "/") { // внешняя ссылка, добавляем значок
      text = "🌐 " + text;
    }
    // рендерим как обычно
    return MD.a(text, href)
  },
  // спонсор это картинка с ссылкой на спонсора
  sponsor: name => {
    const img = MD.image(name, `/docs/images/sponsors/${name}.png`);
    return MD.a(img, `https://${name}/`);
  },
  badge: (color, icon, text) => `https://img.shields.io/badge/${text}-x?logo=${icon}&color=${color}`,
};

Кроме того, предопределенным набором элементов можно считать темы. Пусть мы нашли крутую html тему, в которой есть callout-ы, tab-ы, таблицы с сортировками и т.д. и т.п. Вместо того, чтобы определять ее как кучу шаблонизированных файлов мы можем определить тему, как набор заранее определенных компонентов, возможно используя базовые элементы html, типо таких

const HTML = (function() {
  const render_props = props => props.entries().map((k, v) => `${k}="${v}"`).join(" ");
  return {
    h1: (props, text) => `<h1 ${render_props(props)}>${text}</h1>`,
    div: (props, children...) => `<div ${render_props(props)}>${children.join("")}</div>`,
    ...
  };
})();
Тема тогда становится набором функций, принимающих параметры кастомных элементов, и отдает собственно представление этих элементов:
// тема, которая красит все в зеленый
const theme_green = {
  ...HTML,
  h1: (props, text) => HTML.h1({...props, style="color: green;"}, text),
  body: (children...) => HTML.body({style="background: green;"}, children...),
  ... // другие зеленые элементы
  peace: this.div(this.img("/imgs/peace-logo.png"), "save the planet"),// кастомный элемент
};

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

Кроме того, мы пока что рассматривали исключительно рендер и исключительно текста (условно считаем pdf и img текстом). Но мы можем поддержать дополнительные сценарии.

Например мы можем валидировать документ: проверять правописание, валидность параметров элементов, валидность ссылок, что они еще не сдохли, аналогично валидность картинок, относительных ссылок. И это все на уровне кода, то есть автоматически. Теперь можно не бояться (будто бы кто-то реально боится и перепроверяет ссылки в документации когда ломает их) сломать ссылки, ведь они проверятся во время рендера.

Также, можно генерировать не только текст, но и структуры поверх документа. Вспоминаем пример с Table of contents. Если документ написан в терминах заголовков нескольких уровней, то мы можем написать адаптер, который игнорирует остальные элементы, а заголовки использует, чтобы построить иерархическую структуру документа, которую можно затем использовать, чтобы сгенерировать навигацию на странице или Table of contents. Аналогично можно проанализировать страницу, чтобы сгенерировать краткое описание (взять первый параграф и картинку, если есть) или разметку OpenGraph. Про возможность разбивать документацию по файлам как будет удобнее и инклудить ее, а также считывать файлы с данными для таблиц или списков я вообще молчу.

Последнее по списку, но не последнее по значимости, замечание, что теперь мы можем, вообще говоря, вставлять любые вычисляемые данные в документ. Такие как:

  • примеры кода и результаты его выполнения
  • табличные данные и график по ним
  • диаграммы, написанные на mermaid или любом, повторяю, любом другом языке

В качестве одного из потенциальных минусов, мне в голову пришел следующий: автор документа не захочет разбираться во всем этом, ему надо просто писать текст в некоторой разметке. Подход не заставляет писать в определенной "функциональной" разметке, вы вольны сделать набор элементов таким, как вам удобнее. Кроме того, вы вольны определить свой язык разметки, аля markdown, в котором синтаксически выразить ваши элементы, что сильно облегчит написание документации неспециалистам. Но имейте ввиду, что какой бы язык разметки вы не придумали, для выделения элементов там будут символы начала и конца, а если мы абстрагируемся от того, что это за символы, то язык разметки будет ничем иным, как s-expression-ом (как впрочем и любой, расмотренный в статье).

P.S.

Существует подход docs-as-code. Суть его в том, чтобы писать документы не в условном Word, тыкая кнопочки в WYSIWYG редакторе, а в виде текста, что дает возможность версионировать документацию, ревьювить, автоматически проверять и т.д. Подход, описанный выше, дает все то же самое, и буквально отображает документацию в код, что, как было показано, дает в разы больше возможностей и вместе с тем простоты, чем существующие подходы.

Также вдохновением данного подхода, наверное, можно назвать следующие два доклада: по Angular и по Vue. Суть их в том, что оба фреймворка работают в терминах некоторой "платформы", и переопределяя эту платформу, можно реализовывать приложение на чем угодно в том же фреймворке: хоть на канвасе, хоть в терминале, хоть в pdf. В принципе ничего нового, подобное выделение платформенно независимой прослойки используется с стародавних времен для написания платформонезависимого кода, но все равно было интересно.