В предыдущей части - Как я переносил блог из CakePHP в Angular, я делился своей историей миграции блога из CakePHP в Angular. В этой статье, я хочу продемонстрировать связку Angular и Contentful. Я по шагам создам новое приложение, добавлю необходимые вендоры, а также реализую требуемые скрипты для загрузки и генерации контента.

Исходный код на github

Демо можно посмотреть на angular-blog.fafn.ru

Демонстрация готового проекта Angular & Contentful
Демонстрация готового проекта Angular & Contentful

Создание проекта

Создать проект можно с помощью стандартных angular cli:

ng new angular-blog

Другой подход - использовать Nx. В настоящий момент Nx это не только monorepo, а также набор удобных утилит для разработки на javascript/typescript. Приятным бонусом при использовании Nx является jest и cypress из коробки.

Для генерация нового workspace, достаточно запустить команду: 

yarn create nx-workspace --packageManager=yarn
Результат выполнения команды
Результат выполнения команды

Далее необходимо выполнить пройти шаги:

  • workspace и задать имя (angular-blog);

  • angular и ввести название приложения(blog);

  • выбрать препроцессор (scss);

  • пропустить e2e

  • добавить роутинг

  • отказаться от cloud;

  • подождать установку вендоров.

Проект создан и перехожу к настройке.

cd angular-blog

Конфигурация проекта

Задам несколько правил для workspace в nx.json:

{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  "npmScope": "angular-blog",
  "cli": {
    "packageManager": "yarn",
    "defaultCollection": "@nx/angular"
  },
  "affected": {
    "defaultBase": "develop"
  },
  "tasksRunnerOptions": {
    "default": {
      "runner": "nx/tasks-runners/default",
      "options": {
        "cacheableOperations": ["build", "lint", "test", "e2e"]
      }
    }
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"]
    },
    "test": {
      "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"]
    },
    "e2e": {
      "inputs": ["default", "^production"]
    },
    "lint": {
      "inputs": ["default", "{workspaceRoot}/.eslintrc.json", "{workspaceRoot}/.eslintignore"]
    }
  },
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
      "!{projectRoot}/tsconfig.spec.json",
      "!{projectRoot}/jest.config.[jt]s",
      "!{projectRoot}/src/test-setup.[jt]s",
      "!{projectRoot}/test-setup.[jt]s",
      "!{projectRoot}/.eslintrc.json"
    ],
    "sharedGlobals": []
  },
  "generators": {
    "@nx/angular:application": {
      "style": "scss",
      "linter": "eslint",
      "unitTestRunner": "jest",
      "e2eTestRunner": "cypress"
    },
    "@nx/angular:library": {
      "linter": "eslint",
      "unitTestRunner": "jest"
    },
    "@nx/angular:component": {
      "style": "scss",
      "changeDetection": "OnPush",
      "standalone": true
    },
    "@schematics/angular:component": {
      "style": "scss",
      "changeDetection": "OnPush",
      "standalone": true
    }
  },
  "defaultProject": "blog"
}

В основном это правила для генерации компонентов и библиотек.

Чтобы в репозиторий не попадал мусор, в .gitignore исключу файлы и директории:

...

# Custom
/mocks/tmp
/apps/**/src/assets/locale
package-lock.json
.env
/apps/**/.env
/apps/**/source.xlf
.husky/*
junit.xml
/junit
/apps/**/dynamic-routes.txt
/apps/**/sitemap.xml
.yarnrc
yarn.lock
/apps/**/routes/blog.routes.ts
/apps/**/routes/*.routes.ts
libs/ui/categories/src/lib/categories.ts

Проект должен выглядеть стройно и опрятно. Поэтому добавлю пару расширений в eslint:

yarn add -D eslint-plugin-import eslint-plugin-jsdoc eslint-plugin-prettier eslint-plugin-simple-import-sort

После установки в .eslintrc.json вставляю следующие правила:

{
  "root": true,
  "ignorePatterns": ["**/*"],
  "plugins": ["@typescript-eslint", "prettier", "simple-import-sort", "import", "@angular-eslint/eslint-plugin-template", "@nx", "jsdoc"],
  "env": {
    "browser": true,
    "node": true
  },
  "overrides": [
    {
      "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
      "rules": {
        "@nx/enforce-module-boundaries": [
          "error",
          {
            "enforceBuildableLibDependency": true,
            "allow": [],
            "depConstraints": [
              {
                "sourceTag": "*",
                "onlyDependOnLibsWithTags": ["*"]
              }
            ]
          }
        ]
      }
    },
    {
      "files": ["*.ts"],
      "parserOptions": {
        "project": "./tsconfig.*?.json",
        "createDefaultProgram": true
      },
      "extends": ["plugin:@nx/typescript", "plugin:@nx/angular", "plugin:import/recommended"],
      "rules": {
        "@typescript-eslint/naming-convention": [
          "error",
          {
            "selector": "default",
            "format": ["camelCase"],
            "leadingUnderscore": "allow",
            "trailingUnderscore": "allow",
            "filter": {
              "regex": "^(ts-jest|\\^.*)$",
              "match": false
            }
          },
          {
            "selector": "default",
            "format": ["camelCase"],
            "leadingUnderscore": "allow",
            "trailingUnderscore": "allow"
          },
          {
            "selector": "variable",
            "format": ["camelCase", "UPPER_CASE"],
            "leadingUnderscore": "allow",
            "trailingUnderscore": "allow"
          },
          {
            "selector": "typeLike",
            "format": ["PascalCase"]
          },
          {
            "selector": "enumMember",
            "format": ["PascalCase"]
          }
        ],
        "complexity": "error",
        "max-len": [
          "error",
          {
            "code": 140
          }
        ],
        "no-new-wrappers": "error",
        "no-throw-literal": "error",
        "import/no-unresolved": "off",
        "simple-import-sort/exports": "error",
        "simple-import-sort/imports": [
          "error",
          {
            "groups": [
              ["^\\u0000"],
              ["^@?(?!ekapusta)\\w"],
              ["^@ekapusta?\\w"],
              ["^@?(?!loaney)\\w"],
              ["^@loaney?\\w"],
              ["^\\w"],
              ["^[^.]"],
              ["^\\."]
            ]
          }
        ],
        "sort-imports": "off",
        "import/named": "off",
        "import/first": "error",
        "import/newline-after-import": "error",
        "import/no-duplicates": "error",
        "@typescript-eslint/consistent-type-definitions": "error",
        "no-shadow": "off",
        "@typescript-eslint/no-shadow": "error",
        "no-invalid-this": "off",
        "@typescript-eslint/no-invalid-this": ["warn"]
      }
    },
    {
      "files": ["*.js", "*.jsx"],
      "extends": ["plugin:@nx/javascript"],
      "rules": {}
    },
    {
      "files": ["*.html"],
      "extends": ["plugin:@nx/angular-template"],
      "rules": {
        "max-len": [
          "error",
          {
            "code": 140
          }
        ]
      }
    },
    {
      "files": ["*.component.ts"],
      "extends": ["plugin:@angular-eslint/template/process-inline-templates"]
    }
  ]
}

В .prettierrc задаю предпочитаемые настройки форматирования:

{
  "bracketSpacing": true,
  "printWidth": 140,
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "useTabs": false
}

Чтобы не следить за чистотой кода, устанавливаю еще пару пакетов:

yarn add -D husky lint-staged 

Для husky создаю два хука: pre-push, pre-commit.

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

yarn nx affected:lint
yarn nx affected:test

Также добавляю в корень проекта .lintstagedrc.json с содержимым:

{
  "*": ["nx affected:lint --fix --files", "nx format:write --files"]
}

Установлю пакет universal, который реализует SSR в Angular, чтобы поисковые системы могли индексировать сайт:

yarn ng add @nguniversal/express-engine

Отмечу, что при запуске ng, Nx будет трансформировать и предлагать альтернативный вариант. Если команда падает, то нужно в конце добавить параметр --project=name.

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

ng add @angular/localize

Начиная с 15 версии, в Angular изменился процесс сборки приложения для старых устройств. Необходимо создать файл .browserslistrc и указать требуемые правила. Я обычно использую следующую политику:

>0.3%
last 2 Chrome version
last 2 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR
Chrome > 60
Firefox > 60
not ios_saf 12.2-12.6
not op_mini all
not dead

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

Измененная структура конфигов
Измененная структура конфигов

appConfig:

import { APP_INITIALIZER, ApplicationConfig } from '@angular/core';
import { provideRouter, withEnabledBlockingInitialNavigation, withInMemoryScrolling } from '@angular/router';

import { MetaService, MetricService } from '@angular-blog/core';

import { appRoutes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(
      appRoutes,
      withEnabledBlockingInitialNavigation(),
      withInMemoryScrolling({
        anchorScrolling: 'enabled',
        scrollPositionRestoration: 'enabled',
      })
    ),
    {
      provide: APP_INITIALIZER,
      useFactory: (metaService: MetaService, metricService: MetricService) => {
        return () => {
          metaService.init();
          metricService.init();
        };
      },
      multi: true,
      deps: [MetaService, MetricService],
    },
  ],
};

browserConfig:

import { ApplicationConfig, mergeApplicationConfig } from '@angular/core';
import { provideNoopAnimations } from '@angular/platform-browser/animations';

import { appConfig } from './app.config';

const browserConfig: ApplicationConfig = {
  providers: [provideNoopAnimations()],
};

export const config = mergeApplicationConfig(appConfig, browserConfig);

Установлю hammerjs, который позволяет отлавливать события на смартфонах:

yarn add -D hammerjs

В app.config.browser.ts добавлю импорт hammerjs.

Возможно я не умею настраивать CLI, но чтобы минифицировать HTML я запускаю  следующий скрипт:

import { minify } from 'html-minifier';
import { existsSync, readdirSync, lstatSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

function fromDir(startPath: string, filter: string): string[] {
  if (!existsSync(startPath)) {
    console.warn('no dir ', startPath);
    return [];
  }
  const founded = [];
  const files = readdirSync(startPath);
  for (const file of files) {
    const filename = join(startPath, file);
    const stat = lstatSync(filename);
    if (stat.isDirectory()) {
      const foundedIn = fromDir(filename, filter);
      founded.push(...foundedIn);
    } else if (filename.indexOf(filter) >= 0) {
      founded.push(filename);
    }
  }

  return founded;
}

const files = fromDir(`dist/apps/${process.env.PROJECT ?? ''}`, '.html');

for (const filePath of files) {
  const fileContent = readFileSync(filePath, 'utf8');
  const minifiedValue = minify(fileContent.toString(), {
    removeComments: true,
    collapseWhitespace: true,
    minifyCSS: true,
    minifyJS: true,
  });

  writeFileSync(filePath, minifiedValue);
}

console.log(files);

Для его работы необходимо установить html-minifier:

yarn add -D html-minifier @types/html-minifier

Есть несколько крутых библиотек, упрощающих тестирование в Angular. Добавлю их:

yarn add -D ts-mockito jasmine-marbles ng-mocks

Так как раннер jest испытывает некоторые трудности с DOM, то установлю пакет:

yarn add -D  jest-environment-jsdom

Создам файл jest.jsdom.js:

const JSDOMEnvironment = require('jest-environment-jsdom').default;
const { TextEncoder, TextDecoder } = require('util');

class JSDOMEnvironmentExtended extends JSDOMEnvironment {
  async setup() {
    await super.setup();

    if (typeof this.global.TextEncoder === 'undefined') {
      this.global.TextEncoder = TextEncoder;
      this.global.TextDecoder = TextDecoder;
    }
  }
}

module.exports = JSDOMEnvironmentExtended;

Также необходимо обновить jest.preset.js:

const nxPreset = require('@nx/jest/preset').default;

module.exports = {
  ...nxPreset,
  testEnvironment: `${__dirname}/jest.jsdom.js`,
  collectCoverage: true,
  coverageDirectory: `${process.env.NX_WORKSPACE_ROOT}/coverage/${process.env['NX_TASK_TARGET_PROJECT']}`,
};

Для использования .env в проекте, добавлю dotenv:

yarn add -D dotenv

Создание шаблона приложения

Теперь перейду к разработке шаблона приложения.

Создам библиотеку ui/layout:

nx g lib ui/layout

Сгенерирую компонент layout:

yarn nx g c layout --project=ui-layout

Разметка страницы примет следующий вид в layout.component.ts:

<router-outlet name="top"></router-outlet>
<header>
  <router-outlet name="header"></router-outlet>
</header>
<main>
  <router-outlet name="breadcrumbs"></router-outlet>
  <router-outlet></router-outlet>
</main>
<footer>
  <router-outlet name="footer"></router-outlet>
</footer>
<router-outlet name="bottom"></router-outlet>

Добавлю каплю стилей в layout.component.scss:

:host {
  display: flex;
  min-height: 100%;
  flex-direction: column;
  width: 100%;
}

header,
footer {
  flex-shrink: 0;
}

main {
  flex-grow: 1;
  display: flex;
  flex-direction: column;
  width: 100%;

  & > * {
    width: 100%;
  }
}

В LayoutComponent импортирую RouterOutlet, чтобы блоки выводились в нужных местах.

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  selector: 'angular-blog-layout',
  templateUrl: './layout.component.html',
  styleUrls: ['./layout.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [RouterOutlet],
})
export class LayoutComponent {}

Использую Angular Material, который идеально подходит для моего проекта.

Установлю библиотеки:

yarn add @angular/cdk @angular/material 

В apps/blog/src/stylesheets буду хранить все файлы scss. Скачаю и закину в папку normalize.scss.

Сделаю пару алиасов на переменные variables.scss:

@use '@angular/material' as mat;

$color-default: mat.get-color-from-palette(mat.$gray-palette, 900);
$color-primary: mat.get-color-from-palette(mat.$indigo-palette, 500);
$color-accent: mat.get-color-from-palette(mat.$pink-palette, A200);
$color-warning: mat.get-color-from-palette(mat.$amber-palette, 500);
$color-danger: mat.get-color-from-palette(mat.$red-palette, 900);

Задам немного глобальных стилей global.scss:

@use 'variables' as variables;

*,
::after,
::before {
  box-sizing: border-box;
}

html,
body {
  height: 100%;
  font-size: 16px;
  color: variables.$color-default;
  font-family:
    Roboto,
    'Helvetica Neue',
    Helvetica,
    Arial,
    Lucida Grande,
    sans-serif;
}

body {
  background-color: var(--mat-toolbar-container-background-color);
  color: var(--mat-toolbar-container-text-color);
}

Создам файл material-theme.scss, который будет содержать настройки оформления:

@use '@angular/material' as mat;

@include mat.core();

$blog-primary: mat.define-palette(mat.$indigo-palette, 500);
$blog-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400);

$dark-theme: mat.define-dark-theme(
  (
    color: (
      primary: $blog-primary,
      accent: $blog-accent,
    ),
    typography: mat.define-typography-config(),
    density: 0,
  )
);

$light-theme: mat.define-light-theme(
  (
    color: (
      primary: $blog-primary,
      accent: $blog-accent,
    ),
    typography: mat.define-typography-config(),
    density: 0,
  )
);

@include mat.all-component-themes($dark-theme);

html[data-theme='light'] {
  @include mat.all-component-colors($light-theme);
}

Подключу все в apps/blog/src/styles.scss:

/* You can add global styles to this file, and also import other style files */
@import './stylesheets/normalize';
@import './stylesheets/material-theme';
@import './stylesheets/global';

Можно занятья шапкой и подвалом. Добавлю header:

yarn nx g lib ui/header
yarn nx g c header --project=ui-header

В header выведу toolbar с логотипом, ссылками на гитхаб и о проекте, а также переключателем темы. 

<angular-blog-container>
  <mat-toolbar>
    <angular-blog-menu></angular-blog-menu>
    <angular-blog-logo></angular-blog-logo>
    <span></span>
    <angular-blog-theme-switcher></angular-blog-theme-switcher>
    <angular-blog-about></angular-blog-about>
    <angular-blog-github></angular-blog-github>
  </mat-toolbar>
</angular-blog-container>

header.component.ts

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatToolbarModule } from '@angular/material/toolbar';

import { ContainerComponent } from '@angular-blog/ui/container';

import { AboutComponent } from './about/about.component';
import { GithubComponent } from './github/github.component';
import { LogoComponent } from './logo/logo.component';
import { MenuComponent } from './menu/menu.component';
import { ThemeSwitcherComponent } from './theme-switcher/theme-switcher.component';

@Component({
  selector: 'angular-blog-header',
  templateUrl: './header.component.html',
  styleUrls: ['./header.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [MatToolbarModule, ThemeSwitcherComponent, ContainerComponent, LogoComponent, MenuComponent, AboutComponent, GithubComponent],
})
export class HeaderComponent {}

Из важного тут только ThemeSwitcherComponent и MenuComponent.

MenuComponent - это мобильное меню, в котором выведен список категорий блога.

ThemeSwitcherComponent - свитчер для dark/light темы. Механизм достаточно простой: при клике меняется свойство у <html>. Так как в проекте используется пререндер, значение храниться в куке.

Верстка:

<button mat-icon-button i18n-aria-label="Header|Theme Switcher" aria-label="Toggle dark and light modes" (click)="onToggle()">
  <mat-icon>dark_mode</mat-icon>
</button>

Реализация свитчера:

import { Platform } from '@angular/cdk/platform';
import { DOCUMENT } from '@angular/common';
import { ChangeDetectionStrategy, Component, DestroyRef, Inject, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { tap } from 'rxjs';

import { CookieService, WindowService } from '@angular-blog/core';

@Component({
  selector: 'angular-blog-theme-switcher',
  standalone: true,
  templateUrl: './theme-switcher.component.html',
  styleUrls: ['./theme-switcher.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [MatIconModule, MatButtonModule],
})
export class ThemeSwitcherComponent implements OnInit {
  control!: FormControl<boolean>;
  isDark = true;

  constructor(
    private readonly platform: Platform,
    private readonly windowService: WindowService,
    private readonly cookieService: CookieService,
    private readonly destroyRef: DestroyRef,
    @Inject(DOCUMENT) private readonly document: Document
  ) {}

  get mode(): string {
    return this.isDark ? 'dark' : 'light';
  }

  ngOnInit(): void {
    if (this.platform.isBrowser) {
      const prefers = this.windowService.window.matchMedia('(prefers-color-scheme: dark)').matches;
      const themePreference = this.cookieService.get('themePreference');

      this.isDark = themePreference ? themePreference === 'dark' : prefers ?? true;
      this.control = new FormControl<boolean>(this.isDark, { nonNullable: true });
      this.document.documentElement.setAttribute('data-theme', this.mode);

      this.control.valueChanges
        .pipe(
          tap((dark) => {
            this.isDark = dark;
            this.cookieService.set('themePreference', this.mode);
            this.document.documentElement.setAttribute('data-theme', this.mode);
          }),
          takeUntilDestroyed(this.destroyRef)
        )
        .subscribe();
    }
  }

  onToggle(): void {
    this.control.patchValue(!this.isDark);
  }
}

Добавлю footer:

yarn nx g lib ui/footer
yarn nx g c footer --project=ui-footer

В футоре выведу просто ссылки на приложения в маркетах и копирайт.

Стоит упомянуть про ContainerComponent и RowComponent&ColumnComponent.

Контейнер - блок, который выравнивает содержимое по центру. 

Строка и колонка - авторская реализация сетки из bootstrap. Я подробно рассказывал про эти решения на медиуме.

Создание структуры данных блога в Contentful

Завожу новую учетку в Contentful.

Главная страница личного кабинета в Contentful
Главная страница личного кабинета в Contentful

Cjp

В разделе Content Model создаю новый Content Type - Author

Экшен создания нового типа
Экшен создания нового типа
Форма создания нового типа
Форма создания нового типа

Автор будет обладать следующими свойствами:

  • полным именем (fullName); 

  • электронными адресом (email); 

  • биографией (bio);

  • изображением (avatar).

Созданный тип Author
Созданный тип Author

Для объединения статей по группам, добавлю еще один тип - Category.

В категории будет всего два поля: имя (name) и путь (slug).

Тип Category
Тип Category

Заведу главную сущность в блоге - Post. Публикация получит следующие свойства:

  • дата создания (published);

  • заголовок (title);

  • описание (description);

  • категория (category);

  • изображение  (image);

  • исходный медиа файл (imageOriginal);

  • автор (author);

  • название (headline);

  • интро (intro);

  • путь (slug);

  • содержимое (body);

  • количество просмотров (views);

  • время прочтения (readingTime);

Тип Post
Тип Post

Теперь можно заполнить блог контентом. Перехожу в раздел Content.

Для проекта я буду использовать новости с лучшего информационного ресурса - Панорама.

Добавлю несколько авторов: 

Список авторов
Список авторов

Создам шесть категорий: политика, общество, наука, экономика, статьи и книги.

Список категорий
Список категорий

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

Списко статей
Списко статей

Интерфейсы для работы с Contentful

Создам несколько интерфейсов для загрузки данных из Contentful и размещу их в contentful/common:

yarn nx g lib contentful/common

Каждый элемент в Contentful выражается в sys:

export interface ContentfulSys {
  /**
   * System type ("link")
   */
  readonly type: string;

  /**
   * Link type ("ContentType")
   */
  readonly linkType: string;

  /**
   * Name ("post" | "category" | ...)
   */
  readonly id: string;
}
  • type - тип;

  • linkType - ссылаемый объект;

  • id - имя.

Content Type представляется следующим образом:

export interface ContentfulEntity<T extends object = object> {
  /**
   * Metadata
   */
  readonly metadata: {
    /**
     * Tags
     */
    readonly tags: [];
  };

  /**
   * System
   */
  readonly sys: {
    /**
     * Space (master|stage|testing|...)
     */
    readonly space: {
      readonly sys: ContentfulSys;
    };

    /**
     * UUID
     */
    readonly id: string;

    /**
     * System type
     */
    readonly type: string;

    /**
     * Created date
     */
    readonly createdAt: string;

    /**
     * Updated date
     */
    readonly updatedAt: string;

    /**
     * Environment for space
     */
    readonly environment: {
      readonly sys: ContentfulSys;
    };

    /**
     * Revision
     */
    readonly revision: number;

    /**
     * ContentType
     */
    readonly contentType: {
      readonly sys: ContentfulSys;
    };

    /**
     * Locale
     */
    readonly locale: string;
  };

  /**
   * Entity fields
   */
  readonly fields: T;
}

Contentful имеет один предопределенный тип - Asset, сущность для хранения медиа файлов.

Я не планирую использовать ничего кроме изображений, то тип можно определить так:

export type ContentfulAsset = ContentfulEntity<{
  /**
   * Title
   */
  readonly title: string;

  /**
   * File
   */
  readonly file: {
    /**
     * Original url
     */
    readonly url: string;

    /**
     * Details
     */
    readonly details: {
      /**
       * File size
       */
      readonly size: number;

      /**
       * Image props
       */
      readonly image?: {
        readonly width: number;
        readonly height: number;
      };
    };

    /**
     * File name
     */
    readonly fileName: string;

    /**
     * Content type
     */
    readonly contentType: string;
  };
}>;

Коллекция в API будет отдана в следующем виде:

export interface ContentfulCollection<T extends ContentfulEntity = ContentfulEntity> {
  /**
   * System
   */
  readonly sys: ContentfulSys;

  /**
   * Total
   */
  readonly total: number;

  /**
   * Skip
   */
  readonly skip: number;

  /**
   * Limit
   */
  readonly limit: number;

  /**
   * Items
   */
  readonly items: T[];

  /**
   * Includes entities
   */
  readonly includes?: {
    /**
     * Entities
     */
    readonly Entry: ContentfulEntity[];

    /**
     * Assets
     */
    readonly Asset: ContentfulAsset[];
  };
}

Создам библиотеку и размещу там все необходимое для публикаций:

yarn nx g lib posts/common

Применяя интерфейсы Contentful, получу interface и dto для категорий:

port { ContentfulAsset, ContentfulEntity, ContentfulSys } from '@angular-blog/contentful/common';

/**
 * Category entity
 */
export interface Category {
  /**
   * Name
   */
  readonly name: string;

  /**
   * Slug
   */
  readonly slug: string;
}

/**
 * Category DTO
 */
export type ContentfulCategory = ContentfulEntity<Category>;

Аналогичная ситуация и с авторами:

/**
 * Author entity
 */
export interface Author {
  /**
   * Full name
   */
  readonly fullName: string;

  /**
   * Email
   */
  readonly email: string;

  /**
   * Bio
   */
  readonly bio?: string;

  /**
   * Avatar
   */
  readonly avatar: string;
}

/**
 * Author DTO
 */
export type ContentfulAuthor = ContentfulEntity<
  Omit<Author, 'avatar'> & {
    /**
     * Avatar asset
     */
    readonly avatar: {
      readonly sys: ContentfulSys;
    };
  }
>;

Самым сложной сущностей является публикация со следующей реализацией:

/**
 * Post entity
 */
export interface Post {
  /**
   * Tags
   */
  readonly tags: string[];

  /**
   * Published date
   */
  readonly published: string;

  /**
   * Meta title
   */
  readonly title: string;

  /**
   * Meta description
   */
  readonly description: string;

  /**
   * Category
   */
  readonly category: Category;

  /**
   * Path to image
   */
  readonly image: string;

  /**
   * Original asset for generate OG
   */
  readonly imageOriginal: ContentfulAsset;

  /**
   * Author
   */
  readonly author: Author;

  /**
   * Title
   */
  readonly headline: string;

  /**
   * Intro
   */
  readonly intro: string;

  /**
   * Slug
   */
  readonly slug: string;

  /**
   * Body
   */
  readonly body: string;

  /**
   * Count views
   */
  readonly views?: number;

  /**
   * Reading time in minutes
   */
  readonly readingTime?: number;
}

/**
 * Post DTO
 */
export type ContentfulPost = ContentfulEntity<
  Omit<Post, 'category' | 'image' | 'author' | 'tags'> & {
    /**
     * Category sys link
     */
    readonly category: {
      readonly sys: ContentfulSys;
    };

    /**
     * Image sys link
     */
    readonly image: {
      readonly sys: ContentfulSys;
    };

    /**
     * Author sys link
     */
    readonly author: {
      readonly sys: ContentfulSys;
    };
  }
>;

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

Создам библиотеку и компонент для вывода полной публикации:

yarn nx g lib posts/view/page
yarn nx g c post-page --project=posts-view-page

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

<h1>{{ post.headline }}</h1>
<p>{{ post.published | date : 'shortDate' }}, {{ post.author.fullName }}</p>
<div>
  <img [src]="post.image" alt="" />
</div>
<div [innerHTML]="post.body | safeHtml"></div>

Так как текст статьи это внешний HTML, добавлю pipe для вывода контента:

import { Pipe, PipeTransform, SecurityContext } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';

@Pipe({
  name: 'safeHtml',
  standalone: true,
})
export class SafeHtmlPipe implements PipeTransform {
  constructor(private readonly sanitizer: DomSanitizer) {}

  transform(body: string | null | undefined): string {
    if (!body) {
      return '';
    }

    return this.sanitizer.sanitize(SecurityContext.NONE, this.sanitizer.bypassSecurityTrustHtml(body)) ?? '';
  }
}

Для работоспособности ссылок в теле публикации, необходимо как-то навешать события навигации. Самым простым решением будет просто отслеживание всех a.

import { DatePipe, NgIf } from '@angular/common';
import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, OnDestroy, OnInit, Renderer2 } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';

import { Post } from '@angular-blog/posts/common';

import { SafeHtmlPipe } from './safe-html.pipe';

@Component({
  selector: 'angular-blog-post-view-page',
  templateUrl: './post-view-page.component.html',
  styleUrls: ['./post-view-page.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [NgIf, DatePipe, SafeHtmlPipe],
})
export class PostViewPageComponent implements OnInit, OnDestroy, AfterViewInit {
  post!: Post;

  listenClickFunc!: () => void;

  constructor(
    private readonly activatedRoute: ActivatedRoute,
    private readonly router: Router,
    private readonly elementRef: ElementRef,
    private readonly renderer: Renderer2
  ) {}

  ngOnInit(): void {
    let route = this.activatedRoute.snapshot;

    while (route.firstChild) {
      route = route.firstChild;
    }

    if (route.data['post']) {
      this.post = route.data['post'];
    }
  }

  ngOnDestroy() {
    if (this.listenClickFunc) {
      this.listenClickFunc();
    }
  }

  ngAfterViewInit() {
    const navigationElements = Array.prototype.slice.call(this.elementRef.nativeElement.querySelectorAll('a[routerLink]'));

    navigationElements.forEach((elem) => {
      this.listenClickFunc = this.renderer.listen(elem, 'click', (event) => {
        event.preventDefault();
        void this.router.navigate([elem.getAttribute('routerLink')]);
      });
    });
  }
}

Это не лучшее решение, но для тестового проекта подходит.

Напишите в комментариях, как вы решаете эту проблему.

Создания списка публикаций

Добавлю список превью публикаций:

yarn nx g lib posts/ui/list
yarn nx g c post-list --project=posts-ui-list

Создам карточку для статьи:

yarn nx g lib posts/ui/card
yarn nx g c post-card --project=posts-ui-card

В превью выведу изображение, заголовок и интро.

<mat-card>
  <a [routerLink]="['/', post.slug]" [ngStyle]="post.image | backgroundImage" i18n-aria-label="Post Card|Image" aria-label="Open full post">
  </a>
  <mat-card-header>
    <mat-card-title>{{ post.headline }}</mat-card-title>
  </mat-card-header>
  <mat-card-content>
    <div>{{ post.intro }}</div>
  </mat-card-content>
  <mat-card-actions>
    <a mat-button i18n="Post Card|Read more" [routerLink]="['/', post.slug]">Read more</a>
  </mat-card-actions>
</mat-card>

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

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'backgroundImage',
  standalone: true,
})
export class BackgroundImagePipe implements PipeTransform {
  transform(image: string | null | undefined): object | null {
    return typeof image === 'string' && image.length ? { backgroundImage: `url(${image})` } : null;
  }
}

Теперь в списке с публикациями использую карточку:

<angular-blog-row>
  <angular-blog-column web="6" *ngFor="let post of posts">
    <angular-blog-post-card [post]="post"></angular-blog-post-card>
  </angular-blog-column>
</angular-blog-row>

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

import { NgForOf } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';

import { Post } from '@angular-blog/posts/common';
import { PostCardComponent } from '@angular-blog/posts/ui/card';
import { ColumnComponent, RowComponent, TabletDirective, WebDirective } from '@angular-blog/ui/grid';

@Component({
  selector: 'angular-blog-post-list',
  templateUrl: './post-list.component.html',
  styleUrls: ['./post-list.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [NgForOf, PostCardComponent, ColumnComponent, TabletDirective, WebDirective, RowComponent],
})
export class PostListComponent {
  @Input({ required: true }) posts!: Post[];
}

Главная страница и разделы с категориями

Главная страница и раздел категории имеют общий дизайн, поэтому добавлю лейаут:

yarn nx g lib posts/ui/layout
yarn nx g c post-layout --project=posts-ui-layout

В макете создам сетку из двух колонок. В первой будет содержимое, во второй список разделов.

<angular-blog-container>
  <angular-blog-breadcrumbs></angular-blog-breadcrumbs>
  <angular-blog-row>
    <angular-blog-column tablet="8" web="9">
      <router-outlet></router-outlet>
    </angular-blog-column>
    <angular-blog-column tablet="4" web="3" class="no-mobile">
      <angular-blog-title i18n="Post Layout|Categories">Categories</angular-blog-title>
      <angular-blog-post-categories></angular-blog-post-categories>
    </angular-blog-column>
  </angular-blog-row>
</angular-blog-container>

В мобильной версии скрою категории и выведу их в шапке.

Как я и говорил ранее, в блоге может быть много статей. Добавлю компонент пагинации:

yarn nx g lib posts/ui/pagination
yarn nx g c post-pagination --project=posts-ui-pagination

Для отображения других страниц достаточно знать сколько всего их.

<ng-container *ngIf="links">
  <a mat-raised-button [routerLink]="link.route" *ngFor="let link of links">{{ link.label }}</a>
</ng-container>

Поэтому я генерирую объект с двумя свойствами: current и total. Затем просто циклом создаю требуемые ссылки. 

import { NgForOf, NgIf } from '@angular/common';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { ActivatedRoute, RouterLink } from '@angular/router';

import { NavigationLink } from '@angular-blog/core';

@Component({
  selector: 'angular-blog-post-pagination',
  templateUrl: './post-pagination.component.html',
  styleUrls: ['./post-pagination.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [NgIf, NgForOf, RouterLink, MatButtonModule],
})
export class PostPaginationComponent implements OnInit {
  links!: NavigationLink[];

  constructor(private readonly activatedRoute: ActivatedRoute) {}

  ngOnInit(): void {
    let route = this.activatedRoute.snapshot;

    while (route.firstChild) {
      route = route.firstChild;
    }
    const pagination:
      | {
          readonly page: number;
          readonly total: number;
          readonly route: string;
        }
      | undefined = route.data['pagination'];

    if (pagination && pagination.total !== 1) {
      this.links = Array.from({ length: pagination.total }, (v: unknown, k: number) => {
        return {
          label: `${k + 1}`,
          route: pagination.route === '/feed' && k === 0 ? '/' : k === 0 ? pagination.route : `${pagination.route}/${k + 1}`,
        };
      });
    }
  }
}

Добавлю компонент со списком всех разделов:

yarn nx g lib posts/ui/categories
yarn nx g c post-categories --project=posts-ui-categories

В шаблоне покажу категории:

<mat-card>
  <mat-card-content>
    <angular-blog-categories></angular-blog-categories>
  </mat-card-content>
</mat-card>

Выведу хлебные крошки, чтобы упростить навигацию:

yarn nx g lib ui/breadcrumbs
yarn nx g c breadcrumbs --project=ui-breadcrumbs

Реализация тривиальна:

<ul *ngIf="breadcrumbs.length > 0">
  <li *ngFor="let breadcrumb of breadcrumbs">
    <a [routerLink]="breadcrumb.route">{{ breadcrumb.label }}</a>
  </li>
</ul>

Так как breadcrumbs используются в макете, то необходимо подписаться на событие изменения пути и обновлять их:

import { NgFor, NgIf } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router';
import { filter, tap } from 'rxjs/operators';

import { NavigationLink } from '@angular-blog/core';

@Component({
  selector: 'angular-blog-breadcrumbs',
  templateUrl: './breadcrumbs.component.html',
  styleUrls: ['./breadcrumbs.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [RouterLink, RouterLinkActive, NgIf, NgFor],
})
export class BreadcrumbsComponent implements OnInit {
  breadcrumbs: NavigationLink[] = [];

  constructor(
    private readonly router: Router,
    private readonly activatedRoute: ActivatedRoute,
    private readonly changeDetectorRef: ChangeDetectorRef
  ) {
    this.router.events
      .pipe(
        filter((event) => event instanceof NavigationEnd),
        tap(() => this.update()),
        takeUntilDestroyed()
      )
      .subscribe();
  }

  ngOnInit(): void {
    this.update();
  }

  private update(): void {
    let route = this.activatedRoute.snapshot;

    while (route.firstChild) {
      route = route.firstChild;
    }

    this.breadcrumbs = route.data['breadcrumbs'] ?? [];
    this.changeDetectorRef.markForCheck();
  }
}

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

yarn nx g lib posts/page
yarn nx g c post-page --project=posts-page

В шаблон добавлю заголовок, список публикаций и пагинацию.

<angular-blog-title i18n="Post Page|Last posts">Last posts</angular-blog-title>
<angular-blog-post-list [posts]="posts"></angular-blog-post-list>
<angular-blog-post-pagination></angular-blog-post-pagination>

Скрипт заполнения блога

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

Алгоритм будет следующим:

  • Скачивание списка разделов;

  • Получение всех публикаций и групп статей;

  • Генерация ленты новостей;

  • Создание страницы с полным описанием.

Добавлю библиотеку для утилит:

yarn nx g lib contentful/utils

Функция load:

import { get } from 'node:http';
import { catchError, combineLatest, EMPTY, map, Observable, of, switchMap, take, tap } from 'rxjs';

import { ContentfulCollection, ContentfulEntity } from '@angular-blog/contentful/common';

export interface RequestParams {
  readonly contentType: string;
  readonly limit?: number;
  readonly category?: string;
  readonly skip?: number;
}

export const REQUEST_LIMIT = 100;

export function getUrl(payload: RequestParams): string {
  let path = `http://cdn.contentful.com/spaces/${process.env['NX_CONTENTFUL_SPACE']}/environments/master/entries?access_token=${
    process.env['NX_CONTENTFUL_KEY']
  }&content_type=${payload.contentType}&limit=${payload.limit ?? REQUEST_LIMIT}`;

  if (payload.skip) {
    path += `&skip=${payload.skip}`;
  }
  if (payload.category) {
    path += `&fields.category.sys.id=${payload.category}`;
  }

  return path;
}

export function request<T extends ContentfulEntity = ContentfulEntity>(url: string): Observable<ContentfulCollection<T>> {
  return new Observable((observer) => {
    get(url, (response) => {
      const data: Uint8Array[] = [];

      response.on('data', (fragments) => data.push(fragments));

      response.on('end', () => {
        observer.next(JSON.parse(Buffer.concat(data).toString()) as ContentfulCollection<T>);
        observer.complete();
      });
      response.on('error', (error) => {
        observer.error(error);
        observer.complete();
      });
    });
  });
}

export function load<T extends ContentfulEntity = ContentfulEntity>(payload: RequestParams): Observable<ContentfulCollection<T>> {
  const limit = payload.limit ?? REQUEST_LIMIT;

  return new Observable((observer) => {
    request<T>(getUrl(payload))
      .pipe(
        switchMap((result) => {
          if (limit < result.total) {
            let index = 1;
            const requests = [];

            while (index * limit < result.total) {
              requests.push(
                request<T>(
                  getUrl({
                    limit: payload.limit,
                    contentType: payload.contentType,
                    skip: index * limit,
                  })
                )
              );
              index++;
            }
            /* eslint-disable @typescript-eslint/naming-convention */
            return combineLatest(requests).pipe(
              take(1),
              map((response) => {
                return {
                  ...result,
                  items: [...result.items, ...response.map((item) => item.items).reduce((a, c) => a.concat(c), [])],
                  includes: result.includes
                    ? {
                        Asset: [
                          ...result.includes.Asset,
                          ...response.map((item) => item.includes?.Asset ?? []).reduce((a, c) => a.concat(c), []),
                        ],
                        Entry: [
                          ...result.includes.Entry,
                          ...response.map((item) => item.includes?.Entry ?? []).reduce((a, c) => a.concat(c), []),
                        ],
                      }
                    : { Asset: [], Entry: [] },
                };
              })
            );
            /* eslint-enable @typescript-eslint/naming-convention */
          }

          return of(result);
        }),
        tap((result) => {
          observer.next(result);
          observer.complete();
        }),
        catchError((error) => {
          observer.error(error);
          observer.complete();

          return EMPTY;
        })
      )
      .subscribe();
  });
}

Суть в следующем:

  1. Делаем первый запрос.

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

  3. Все ответы объединяем в один, в частности это коллекция items и includes.

Добавлю каст DTO в обычные объекты:

import * as markdown from 'markdown-it';

import { ContentfulAsset, ContentfulCollection } from '@angular-blog/contentful/common';
import { ContentfulAuthor, ContentfulCategory, ContentfulPost, Post } from '@angular-blog/posts/common';

const md = markdown();

export function castPost(
  postDto: ContentfulPost,
  categories: Record<string, ContentfulCategory>,
  authors: Record<string, ContentfulAuthor>,
  images: Record<string, ContentfulAsset>
): Post {
  return {
    tags: postDto.metadata.tags,
    published: postDto.fields.published ?? postDto.sys.createdAt,
    title: postDto.fields.title,
    description: postDto.fields.description,
    category: {
      slug: categories[postDto.fields.category.sys.id].fields.slug,
      name: categories[postDto.fields.category.sys.id].fields.name,
    },
    image: images[postDto.fields.image.sys.id].fields.file.url,
    imageOriginal: images[postDto.fields.image.sys.id],
    author: {
      fullName: authors[postDto.fields.author.sys.id].fields.fullName,
      email: authors[postDto.fields.author.sys.id].fields.email,
      avatar: images[authors[postDto.fields.author.sys.id].fields.avatar.sys.id].fields.file.url,
      bio: authors[postDto.fields.author.sys.id].fields.bio,
    },
    headline: postDto.fields.headline,
    intro: postDto.fields.intro,
    slug: postDto.fields.slug,
    body: postDto.fields.body?.length > 0 ? md.render(postDto.fields.body).replace(/(\r\n|\n|\r)/gm, '') : '',
    views: postDto.fields.views,
    readingTime: postDto.fields.readingTime,
  };
}

export function castPosts(data: ContentfulCollection<ContentfulPost>): Post[] {
  const categories: Record<string, ContentfulCategory> = {};
  const authors: Record<string, ContentfulAuthor> = {};
  const images: Record<string, ContentfulAsset> = {};

  if (data.includes) {
    data.includes.Entry.forEach((entry) => {
      if (entry.sys.contentType.sys.id === 'category') {
        categories[entry.sys.id] = entry as ContentfulCategory;
      } else if (entry.sys.contentType.sys.id === 'author') {
        authors[entry.sys.id] = entry as ContentfulAuthor;
      }
    });

    data.includes.Asset.forEach((asset) => {
      images[asset.sys.id] = asset as ContentfulAsset;
    });
  }

  return data.items.map((item) => castPost(item, categories, authors, images));
}

Создам утилиту генерации страницы с полным описанием:

import { ContentfulCollection } from '@angular-blog/contentful/common';
import { ContentfulPost, Post } from '@angular-blog/posts/common';

import { castPosts } from './cast.util';

export interface RoutePayload {
  readonly data: ContentfulCollection<ContentfulPost>;
  readonly template: (posts: Post[], index: number, total: number) => string;
  readonly templateView?: (post: Post) => string;
  readonly limit?: number;
}

export function createRoutes(payload: RoutePayload): string[] {
  const routes: string[] = [];
  const posts: Post[] = castPosts(payload.data);

  const total = posts.length;
  const limit = payload.limit ?? 4;

  for (let index = 0; index * limit < posts.length; index++) {
    routes.push(payload.template(posts.slice(index * limit, (index + 1) * limit), index, Math.ceil(total / limit)));
  }

  const templateView = payload.templateView;
  if (typeof templateView === 'function') {
    posts.forEach((post) => {
      routes.push(templateView(post));
    });
  }

  return routes;
}

Шаблоны поста и категории вынесу в отдельные методы:

import { Post } from '@angular-blog/posts/common';

export function getPostViewRoure(post: Post): string {
  return `  {
    path: '',
    loadComponent: () => import('@angular-blog/posts/view/page').then((modules) => modules.PostViewPageComponent),
    data: {
      post: ${JSON.stringify(post)},
      sitemap: {
        loc: '/${post.slug}',
      },
      meta: {
        title: '${post.title}',
        description: '${post.description}',
        image: '${post.image}',
        imageType: '${post.imageOriginal.fields.file.contentType}',
        imageWidth: '${post.imageOriginal.fields.file.details.image?.width ?? 800}',
        imageHeight: '${post.imageOriginal.fields.file.details.image?.height ?? 450}',
      },
      breadcrumbs: [
        {
          label: 'Блог',
          route: '/',
        },
        {
          label: '${post.category.name}',
          route: '/category/${post.category.slug}',
        },
      ],
    },
  }`;
}

export function getPostCategoryRoute(posts: Post[], index: number, total: number): string {
  const category = posts[0].category;

  return `  {
    path: '',
    loadComponent: () => import('@angular-blog/posts/page').then((modules) => modules.PostPageComponent),
    data: {
      posts: ${JSON.stringify(posts)},
      sitemap: {
        loc: '/category/${category.slug}${index > 0 ? '/' + (index + 1) : ''}',
      },
      meta: {
        title: '${category.name} от ${new Date().toLocaleDateString()} - Angular blog',
        description: 'Последние новости в категории: ${category.name}',
      },
      breadcrumbs: [
        {
          label: 'Блог',
          route: '/',
        },
        {
          label: '${category.name}',
          route: '/category/${category.slug}',
        },
      ],
      pagination: {
        page: ${index + 1},
        total: ${total},
        route: '/category/${category.slug}',
      },
    },
  }`;
}

export function getHomeRoute(posts: Post[], index: number, total: number): string {
  return `  {
    path: '',
    loadComponent: () => import('@angular-blog/posts/page').then((modules) => modules.PostPageComponent),
    data: {
      posts: ${JSON.stringify(posts)},
      sitemap: {
        loc: '${index > 0 ? '/feed/' + (index + 1) : '/'}',
      },
      meta: {
        title: 'Новости от ${new Date().toLocaleDateString()} - Angular blog',
        description: 'Последние новости на сайте',
      },
      breadcrumbs: [
        {
          label: 'Блог',
          route: '/',
        },
      ],
      pagination: {
        page: ${index + 1},
        total: ${total},
        route: '/feed',
      },
    },
  }`;
}

export function getRouteSeparate(path: string, hash: string | number): string {
  return `  {
    path: '${path}',
  loadChildren: () => import('./blog-${hash}.routes').then((modules) => modules.blogRoutes),
  }`;
}

Для записи результатов воспользуюсь этим:

import { writeFileSync } from 'node:fs';

import { getRouteSeparate } from './route.util';

export function writeRoutes(fileName: string, routes: string[]): void {
  writeFileSync(
    fileName,
    `import { Route } from '@angular/router';\n\n/* eslint-disable max-len */\nexport const blogRoutes: Route[] = [\n${routes.join(
      ',\n',
    )}\n];`,
  );
}

export function writeCategories(fileName: string, categories: object[]): void {
  writeFileSync(
    fileName,
    // eslint-disable-next-line max-len
    `import { Category } from '@angular-blog/posts/common';\n\n/* eslint-disable max-len */\nexport const categories: Category[] = ${JSON.stringify(
      categories,
    )};`,
  );
}

export function writeRoutesSeparate(fileName: string, routes: string[]): void {
  const separateRoutes: string[] = [];

  routes.forEach((route, index) => {
    writeRoutes(`apps/blog/src/app/routes/blog-${index}.routes.ts`, [route]);
    const match = route.match(/loc: '.+?'/);
    const path = match ? match[0].slice(7, -1) : '';

    separateRoutes.push(getRouteSeparate(path, index));
  });

  writeRoutes(fileName, separateRoutes);
}

В конце реализую функцию, которая будет загружать данные и генерировать страницы:

import { combineLatest, switchMap, take, tap } from 'rxjs';

import { ContentfulCollection } from '@angular-blog/contentful/common';
import { ContentfulCategory, ContentfulPost } from '@angular-blog/posts/common';

import { createRoutes } from './create.util';
import { load } from './load.util';
import { getHomeRoute, getPostCategoryRoute, getPostViewRoure } from './route.util';
import { generateSitemap } from './sitemap.util';
import { writeCategories, writeRoutesSeparate } from './write.util';

export function generate(payload: { readonly categoryPath: string; readonly postsPath: string; readonly pageLimit?: number }): void {
  const categories: Record<string, object> = {};

  load<ContentfulCategory>({ contentType: 'category' })
    .pipe(
      tap((response) =>
        response.items.forEach((item) => {
          categories[item.sys.id] = {
            name: item.fields.name,
            slug: item.fields.slug,
          };
        }),
      ),
      switchMap((response) => {
        const requests = [load<ContentfulPost>({ contentType: 'post' })];

        if (response.items.length > 0) {
          requests.push(
            ...response.items.map((item) =>
              load<ContentfulPost>({
                contentType: 'post',
                category: item.sys.id,
              }),
            ),
          );
        }

        // First, we will load all posts, second we will load posts by category
        return combineLatest(requests);
      }),
      take(1),
      tap((result: ContentfulCollection<ContentfulPost>[]) => {
        const categoriesWithPosts: object[] = [];

        const routes = result
          .map((data, index) => {
            if (index === 0) {
              return createRoutes({
                data,
                template: getHomeRoute,
                templateView: getPostViewRoure,
                limit: payload.pageLimit,
              });
            }

            if (data.items.length > 0) {
              const category = categories[data.items[0].fields.category.sys.id];

              if (category) {
                categoriesWithPosts.push(category);
              }
            }

            return createRoutes({
              data,
              template: getPostCategoryRoute,
              limit: payload.pageLimit,
            });
          })
          .reduce((acc: string[], current: string[]) => acc.concat(current), [] as string[]);

        // Write posts
        writeRoutesSeparate(payload.postsPath, routes);

        // Write categories for menu
        writeCategories(payload.categoryPath, categoriesWithPosts);

        // Generate sitemap and routes for prerender
        generateSitemap('blog');
      }),
    )
    .subscribe();
}

Создам load-content.ts, который будет вызывать generate:

import { config } from 'dotenv';

import { generate } from '@angular-blog/contentful/utils';

config({
  path: 'apps/blog/.env',
});

generate({
  categoryPath: 'libs/ui/categories/src/lib/categories.ts',
  postsPath: 'apps/blog/src/app/routes/blog.routes.ts',
});

Сборка приложения

Генерация блога выполняется запуском команды:

yarn ts-node --project=apps/blog/tsconfig.contentfull.json apps/blog/src/load-content.ts
Загрузка и генерация блога
Загрузка и генерация блога

Сборка проекта:

yarn nx run blog:prerender:production
Результат сборки проекта
Результат сборки проекта

Модифицируем HTML:

PROJECT=blog yarn ts-node minifier.ts

Для SSR добавлю раздачу шаблонов dark/light темы:

PROJECT=blog yarn ts-node ./scripts/light-mode.ts

Запуск сервера:

node dist/apps/blog/server/ru/main.js
Результат запуск сервера
Результат запуск сервера

Откроем в браузере:

Итоговое приложение
Итоговое приложение

В мобильной версии:

Примерное отображение в iPhone 12 Pro
Примерное отображение в iPhone 12 Pro

Резюме

Трудно написать веселый туториал. Кратко изложу проделанную работу.

  1. Сначала создается приложение со всякой вкусовщиной.

  2. Потом добавляются две страницы. Одна для вывода списка материалов, вторая для отображения полной публикации.

  3. После в Contentful заводится структура блога, где определяются сущности и соответствующие материалы.

  4. В конце идет написание скрипта, который выгребает из Contentful все статьи и генерирует страницы для Angular.

Фичи, которые есть в приложении, но опущены в рамках статьи:

  • Настройка SSR и пререндера;

  • Создание карты сайта;

  • Добавления мета тегов для SEO;

  • Использование локализации;

  • Переключение тем (светлая/темная);

  • Оптимизация генерации роутов для ускорения работы Angular router.

Исходный код на github поможет более подробно ознакомиться с проектом — https://github.com/Fafnur/angular‑blog.

Комментарии (0)