Работа с датами создания и обновления контента в Eleventy

В статье рассматриваются средства по работе с датами в Eleventy, показано, как добавить свои поля на основе Git, а далее демонстрируется плагин, в котором реализованы идеи статьи.

При создании сайтов частой задачей является упорядочивание контента, например, статей в блоге. Генератор статических сайтов Eleventy для каждой страницы предоставляет полезный набор данных page, в котором хранится url страницы, путь до файла-исходника и некоторые другие свойства. Среди них важным является page.date. На его основе Eleventy сортирует коллекции страниц, только если мы сами не задали свои правила сортировки.

По умолчанию в page.date помещается дата создания файла. Но это значение можно настроить. Для этого есть специальное поле date, которое указывается в frontmatter-секции шаблонов или любом другом источнике данных Eleventy.

Туда можно записать любое стандартное (ISO 8601) значение даты.

---
date: 2016-01-01
---

Кроме этого, поле date может принимать особые значения-константы:

  • Last Modified - дата последнего изменения файла.
  • Created - дата создания файла.
  • git Last Modified - дата последнего изменения файла, которая вычисляется с помощью системы контроля версий Git.
  • git Created - дата создания файла, которая вычисляется с помощью системы контроля версий Git.

Пример даты на основе Git:

---
date: git Created
---

Как видите, довольно удобно - дату можно брать из файловой системы, из Git или задавать вручную.

Преимущество Git-способа является в том, что процесс установки даты автоматизируется. Но есть и минусы – такие вычисления требуют довольно много ресурсов, особенно для даты создания, так как нужна вся история изменений. Сложности могут возникнуть при использовании различных CI/CD-инструментов, таких как Github Actions. Например, Checkout делает по умолчанию поверхностное (shallow) клонирование репозитория. В этом случае даты могут вычисляться некорректно.

Одним из способов решения проблемы может быть указание в настройках Checkout параметра fetch-depth: '0'. Тогда даты будут верными, но будет выкачиваться вся история, все ветки и все теги.

- name: Checkout
  uses: actions/checkout
  with:
    fetch-depth: '0'

Такие настройки необходимы для получения даты создания, но если нужна только дата последнего изменения, то есть другой способ - вручную склонировать репозиторий c последним коммитом, а вспомогательную информацию забрать с помощью git fetch:

- name: Checkout
  run: |
    git clone --depth 1 <YOUR_REPO> .
    git fetch --unshallow

Eleventy даёт только одно поле date, но что если нам нужно несколько полей для работы с датами, например, для даты публикации поста и для даты последнего обновления поста. Реализуем их сами и назовём как publishedAt и updatedAt.

Поле updatedAt будем вычислять через Git, а publishedAt будем задавать вручную в frontmatter-секции:

---
publishedAt: 2024-1-30
---

Теперь нам нужно для дальнейшего удобства работы преобразовывать эти данные из строки в объекты Date и сортировать коллекции на этой основе.

Преобравазование даты можно выполнять в общем для всех статей data-файле. Например, если все статьи хранятся в папке articles, то нужно создать в ней файл articles.11tydata.js:

articles
  |_ article1
    |_ index.md
  |_ article2
    |_ index.md
  articles.11tydata.js

Воспользуемся мощной функциональностью Eleventy - вычисляемые поля eleventyComputed.

// articles.11tydata.js
module.exports = {
  eleventyComputed: {
    publishedAt(data) {
      if (!data.publishedAt) {
        return;
      }
      return new Date(data.publishedAt);
    }
  }
}

С помощью такой техники мы создаём новое одноимённое поле, которое получает доступ к старому строковому значению и возвращает объект типа Date.

Отсортируем коллекцию постов так, чтобы сначала шли новые статьи. Для этого воспользуемся методами для работы с коллекциями внутри конфигурационного файла eleventy.config.js:

module.exports = function(eleventyConfig) {
  eleventyConfig.addCollection('articles', (collectionAPI) => {
    return collectionAPI
      .getFilteredByGlob('src/articles/*/index.md')
      .toSorted((a, b) => b.data.publishedAt - a.data.publishedAt);
  });
}

Здесь мы ищем все нужные нам файлы статьей index.md, предполагая, что они хранятся внутри папки src/articles.

Для красивого форматирования дат внутри шаблонов можно создать свой фильтр или завести ещё одно вычисляемое поле в articles.11tydata.js.

Пример с вычисляемым полем:

// articles.11tydata.js
module.exprots = {
  eleventyComputed: {
    formattedPublishDate(data) {
      if (!data.publishedAt) {
        return;
      }
      return data.publishedAt.toLocaleString('ru', {
        day: 'numeric',
        month: 'long',
        year: 'numeric'
      });
    }
  }
}

Пример с фильтром:

// eleventy.config.js
module.exports = function(eleventyConfig) {
  eleventyConfig.addFilter('formatDate', function (date) {
    date = typeof date === 'string' ? new Date(date) : date;
    return date.toLocaleString('ru', {
      day: 'numeric',
      month: 'long',
      year: 'numeric'
    });
  });
}

Наконец, можно использовать дату публикации в шаблонах. Hа примере шаблонизатора Nunjucks отрендерим список статей:

<!-- index.njk -->
{% for article in collections.articles %}
  <article> 
   <h3>{{ article.data.title }}</h3>
   <!-- Пример с вычисляемым полем: -->
   <time>{{ article.data.formattedPublishDate }}</time> 
   <!-- Пример с фильтром: -->
   <time>{{ article.data.publishedAt | formatDate }}</time> 
  </article> 
{% endfor %}

Теперь реализуем поле updatedAt. В терминах Git это дата последнего коммита. Её можно найти с помощью такой команды:

git --no-pager log -n 1 --format="%ci <path>

В ней нужно будет заменить <path> на путь к нужному файлу или папке. Работать будем именно с папкой, содержащей статью, так как там могут быть и другие связанные с ней материалы, например, картинки. Их правки тоже стоит учитывать.

Будем запускать Git как внешний процесс через Node.js-модуль child_process. А завернём всё это снова в вычисляемое поле, которое можеть быть асинхронной функцией:

// articles.11tydata.js
const path = require('node:path');
const util = require('node:util');
const { execFile } = require('node:child_process');

async function runCommand(command) {
  const [bin, ...args] = command.split(' ');
  const { stdout } = await util.promisify(execFile)(bin, args, {
    encoding: 'utf-8'
  });
  return stdout;
}

async function parseLastCommitDate(contentPath) {
  const command = `git --no-pager log -n 1 --format="%ci" ${contentPath}`;
  const date = (await runCommand(command)).trim();
  return date ? new Date(date) : null;
}

module.exports = {
  eleventyComputed: {
    updatedAt(data) {
      const contentPath = path.dirname(data.page.inputPath);
      return parseLastCommitDate(contentPath);
    }
  }
}

Можем дополнить рендер шаблона новым типом даты:

<!-- index.njk -->
{% for article in collections.articles %}
  <article> 
   <h3>{{ article.data.title }}</h3>
   Дата публикации: <time>{{ article.data.publishedAt | formatDate }}</time> 
   Дата обновления: <time>{{ article.data.updatedAt | formatDate }}</time> 
  </article> 
{% endfor %}

Все вышеперечисленные идеи я завернул в плагин, добавив возможность использовать ключевые слова, подобные тем, что есть в Eleventy для date:

  • Date. FS. Created
  • Date. FS. Last Modified
  • Date. Git. Created
  • Date. Git. Last Modified

Названия говорят сами за себя. Указывать их можно как в frontmatter-разделе:

---
createdAt: 'Date. Git. Created'
updatedAt: 'Date. Git. Last Modified'
---

<time datetime="{{ createdAt.toISOString()}}">
  {{ createdAt.toLocaleDateString() }}
</time>

<time datetime="{{ updatedAt.toISOString()}}">
  {{ updatedAt.toLocaleDateString() }}
</time>

Так и в 11tydata-файлах:

const { TIMESTAMPS } = require('@web-alchemy/eleventy-plugin-content-dates');

module.exports = {
  createdAtWithFS: TIMESTAMPS.FS_CREATED,
  updatedAtWithFS: TIMESTAMPS.FS_LAST_MODIFIED,
  createdAtWithGit: TIMESTAMPS.GIT_CREATED,
  updatedAtWithGit: TIMESTAMPS.GIT_LAST_MODIFIED,
}

Функции из плагина можно использовать как библиотеку без необходимости регистрировать плагин. Подробнее в README репозитория.