В предыдущей части - Как я переносил блог из CakePHP в Angular, я делился своей историей миграции блога из CakePHP в Angular. В этой статье, я хочу продемонстрировать связку Angular и Contentful. Я по шагам создам новое приложение, добавлю необходимые вендоры, а также реализую требуемые скрипты для загрузки и генерации контента.
Демо можно посмотреть на angular-blog.fafn.ru.
Создание проекта
Создать проект можно с помощью стандартных 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.
Cjp
В разделе Content Model
создаю новый Content Type
- Author
.
Автор будет обладать следующими свойствами:
полным именем (
fullName
);электронными адресом (
email
);биографией (
bio
);изображением (
avatar
).
Для объединения статей по группам, добавлю еще один тип - Category
.
В категории будет всего два поля: имя (name
) и путь (slug
).
Заведу главную сущность в блоге - Post
. Публикация получит следующие свойства:
дата создания (
published
);заголовок (
title
);описание (
description
);категория (
category
);изображение (
image
);исходный медиа файл (
imageOriginal
);автор (
author
);название (
headline
);интро (
intro
);путь (
slug
);содержимое (
body
);количество просмотров (
views
);время прочтения (
readingTime
);
Теперь можно заполнить блог контентом. Перехожу в раздел 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();
});
}
Суть в следующем:
Делаем первый запрос.
Если сущностей больше, то создаем несколько параллельных реквестов на загрузку данных и ждем их выполнения. Иначе обрабатываем результат.
Все ответы объединяем в один, в частности это коллекция
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
Откроем в браузере:
В мобильной версии:
Резюме
Трудно написать веселый туториал. Кратко изложу проделанную работу.
Сначала создается приложение со всякой вкусовщиной.
Потом добавляются две страницы. Одна для вывода списка материалов, вторая для отображения полной публикации.
После в Contentful заводится структура блога, где определяются сущности и соответствующие материалы.
В конце идет написание скрипта, который выгребает из Contentful все статьи и генерирует страницы для Angular.
Фичи, которые есть в приложении, но опущены в рамках статьи:
Настройка SSR и пререндера;
Создание карты сайта;
Добавления мета тегов для SEO;
Использование локализации;
Переключение тем (светлая/темная);
Оптимизация генерации роутов для ускорения работы Angular router.
Исходный код на github поможет более подробно ознакомиться с проектом — https://github.com/Fafnur/angular‑blog.