Я давно интересовался созданием минималистичной системы управления контентом. Системы управления контентом (CMS) — это то, чем я занимаюсь в своей повседневной работе, и я чувствовал, что в этой области есть много возможностей для использования современной веб-платформы. Я видел и использовал несколько новых продуктов в этой области, но по целому ряду причин мне захотелось разработать собственное решение. В свете этого я начал проверять концепции с Neo4j и разработал множество функций CMS, основанных на этой графовой базе данных.

Самое интересное, что сделал Neo4j, — это несколько интерпретаций иерархии данных вместо единой канонической иерархии данных. Это стало возможным благодаря тому, что два узла в Neo4j могли быть соединены несколькими ребрами. Однако я обнаружил, что, хотя в этой области было много интересных возможностей, я хотел сократить масштаб и попробовать CMS за один день усилий, и я видел, что мои планы с Neo4j, хотя и мощные, потребуют месяцев, чтобы привести в порядок.

Именно здесь я переключился на использование Firebase, а именно базы данных Firestore и статического хостинга. Моими целями были следующие:

  1. Возможность локализации и перевода в зависимости от страны и языка.
  2. Иерархия страниц, хранящихся в виде данных в базе данных, где из иерархии могут быть получены хлебные крошки, домашняя страница, дочерние страницы, навигационные ссылки и т. д.
  3. Содержимое страниц также будет существовать в виде данных в базе данных. В какой-то степени даже формат и расположение могут определяться данными.
  4. Никакой серверной логики или рендеринга. Это означает, что рендеринг страницы должен происходить на стороне клиента.
  5. Очень ограниченное количество вендорских JS, отправляемых в браузер.

Как я подошёл к решению этих задач? Начнем с просмотра данных.

Определение иерархии данных

Выбранная технология

Firestore — это база данных документов. Наиболее распространенным примером этого может быть MongoDB, но есть и много других. В базе данных документов существует два объекта верхнего уровня: документы и коллекции. Правила просты, документы содержат данные, обычно в форме, которую легко преобразовать или представить в виде JSON, а коллекции содержат ряд документов, где каждый документ имеет уникальный идентификатор. Кроме того, документ может сам содержать коллекции.

Определение иерархии

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

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

Каждая из стран будет содержать коллекцию с именем children, которая содержит дочерние страницы страницы страны. Каждая из этих дочерних коллекций будет содержать ряд документов, представляющих языки, доступные в этой стране. Идентификаторы этих документов будут представлять собой двухбуквенный код языка, например «en» или «fr».

Эти языковые страницы, в свою очередь, будут иметь дочернюю коллекцию, представляющую дочерние страницы языка. Отсюда вы можете структурировать свои страницы по своему вкусу и потребностям, однако для каждого документа требуется коллекция под названием «дети», которая представляет дочерние страницы этой страницы (если у нее есть дочерние страницы). Я сделал это таким образом, чтобы иметь «домашний» документ в дочерней коллекции языка, а затем иметь серию дочерних страниц в дочерней коллекции домашних страниц. Помните, что каждый из этих документов представляет собой страницу веб-сайта и в конечном итоге будет доступен через URL-адрес, уникальный для этой страницы.

Содержание страниц

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

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

Рендеринг страницы

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

Инициализируйте базу данных Firestore и импортируйте lit-html

<div class="page-container"></div>
<script type="module">
  import {html, render} from '/vendor/lit-html.js';
  let app = firebase.app();
  var db = firebase.firestore();
  // The firebase website told me to do this.
  db.settings({
    timestampsInSnapshots: true
  });
</script>

Это предполагает настройку хостинга Firestore по умолчанию. Весь код JavaScript, который мы добавим позже в этом сообщении блога, будет добавлен к этому тегу скрипта.

Определение маршрутизатора

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

function getPath(path) {
  const truePath = ['', '/'].includes(path) ? '/us/en/home' : path;
  const parts = truePath.substring(1).split('/')
  let query = db.collection('pages');
  parts.forEach((part, index) => {
    query = query.doc(part);
    if (index !== parts.length -1) query = query.collection('children');
  });
  return query;
}

Предположения, которые мы здесь сделали, заключаются в том, что если не указан пригодный для использования путь, мы будем перенаправлять на домашнюю страницу на английском языке в США. Затем мы запрашиваем коллекцию страниц. Наконец, мы перебираем каждую часть пути и предполагаем, что каждая часть является именем документа в текущей коллекции, а затем извлекаем «дочернюю» коллекцию в этом документе.

Это формирует запрос, который принимает URL-адрес, такой как «/us/en/home/example». Он вернет запрос Firestore для документа с идентификатором «example», расположенным в следующем месте:

  • страницы -› нас -› дети -› en -› дети -› главная -› дети -› пример

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

getPath('/us/en/home/example').get().then(page => page.data())

Теперь мы можем использовать эти данные для создания HTML-кода страницы.

Отображение данных в виде HTML

Для этого я использовал lit-html. Для этого мы используем наш метод getPath для формирования запроса для нашей страницы, а затем рендерим шаблон в определенное место на странице с помощью lit-html. Когда запрос завершится, наше обещание будет разрешено, и DOM будет обновлен с нашим заголовком и описанием. Это предполагает, что эти свойства существуют в документе в базе данных, представляющей эту страницу.

let pageQuery = getPath(window.location.pathname);
render(html`
  ${pageQuery.get().then(page => html`
    <h1>${page.data().title}</h1>
    <p>${page.data().description}</p>
  `)}
`, document.querySelector('.page-container'));

Контекстный рендеринг

Что, если нам нужна ссылка на «домашнюю страницу» или список ссылок на страницы верхнего уровня под главной страницей? Это называется контекстным рендерингом.

let pageQuery = getPath('/us/en/home');
let homePageQuery = getHomePageQuery(pageQuery)
                    .then(homePageQuery => homePageQuery.get());
let navPagesQuery = getNavPagesQuery(pageQuery);
homePageQuery.then(homePage => console.log(homePage.data()));
render(html`
  ${pageQuery.get().then(page => html`
    <h1>${page.data().title}</h1>
    <p>${page.data().description}</p>
  `)}
  <div class="navigation">
    ${homePageQuery.then(homePage => html`
      <a href="${homePage.path}"
         class="${homePage.path === window.location.pathname ? 'active' : ''}">
        ${homePage.title}
      </a>
    `)}
    ${navPagesQuery.then(navPages => navPages.map(navPage => html`
      <a href="${navPage.type === 'redirect' ? navPage.redirect : navPage.path}"
         target="${navPage.type === 'redirect' ? '_blank' : ''}"
         class="${navPage.path === window.location.pathname ? 'active' : ''}">
        ${navPage.title}
      </a>
    `))}
  </div>
`, document.querySelector('.page-container'));

Вы заметите, что мы используем свойство «путь» документа Firestore, которое представляет страницу, чтобы установить href ссылок. Это означает, что нам нужно указать канонический путь в свойстве «path» каждой страницы. Мы также могли бы генерировать это значение для каждого документа, просматривая иерархию и удаляя имена коллекций, или используя ловушку базы данных для генерации этих значений при создании документов, представляющих страницы.

Здесь мы определяем набор ссылок с помощью двух новых функций, getHomePagesQuery и getNavPagesQuery. Эти функции будут принимать текущий запрос страницы и сканировать вверх по иерархии страниц и коллекций, чтобы найти документ со свойством «тип» = «дом». Как только эта домашняя страница будет найдена, функция getHomePagesQuery вернет запрос для этой страницы. Затем функция getNavPagesQuery сформулирует запрос для всех дочерних страниц домашней страницы и вернет этот новый запрос.

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

function getHomePageQuery(pageQuery) {
  return (async () => {
    let type = (await pageQuery.get()).data().type;
    while (type != 'home') {
      pageQuery = pageQuery.parent.parent;
      type = (await pageQuery.get()).data().type;
    }
    return pageQuery;
  })();
}
function getNavPagesQuery(pageQuery) {
  let homePageQuery = getHomePageQuery(pageQuery);
  
  return homePageQuery
  .then(homePageQuery => homePageQuery.collection('children').get())
  .then(children => {
    let firstLevelPages = [];
    children.forEach(child => {
      firstLevelPages.push(child.data());
    });
    return firstLevelPages
  });
}

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

Расширение парадигмы

Этот пример — только начало использования Firebase Firestore в качестве иерархической системы управления контентом. Приведенные здесь примеры нуждаются в доработке и дальнейшей организации, но основная концепция контекстных данных и рендеринга с иерархией страниц была продемонстрирована. Я использую эту концепцию в качестве отправной точки для моего личного веб-сайта обо мне: about.alexlockhart.me.

То, что я описал здесь, не связано с выбранными технологиями. Фактически, единственными технологиями, которые требовались, были база данных документов с API JavaScript на основе обещаний и библиотека рендеринга на стороне клиента. Для этого я выбрал Firebase Firestore и lit-html, но доступно много других вариантов. Дело в том, что речь идет не о конкретной используемой технологии, а об используемой парадигме.

Содержание страницы

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

СПА + ПВА

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

Интеграция перевода

Наконец, мы могли бы также реализовать хуки базы данных и API перевода для создания иерархий страниц других стран и языков при внесении обновлений в иерархию английского языка США. Я планирую также сделать запись в блоге об этом, так что следите за обновлениями!

Ознакомьтесь с моими мыслями и идеями в моем блоге на www.alexlockhart.me и узнайте больше обо мне на about.alexlockhart.me.