Strapi: CMS за 0 рублей
Всем привет! Меня зовут Александр, я фронтенд-разработчик в KTS. Сегодня я расскажу о Strapi CMS, разберу сценарии ее использования на конкретных примерах и поделюсь способами упрощения работы в ней.
Начну с небольшой предыстории. Наша компания часто разрабатывает проекты, где необходимо регулярно обновлять и настраивать контент сайта. Для таких случаев мы используем различные CMS-системы, позволяющие работать с контентом при помощи графического интерфейса.
При использовании такой CMS-системы хочется, чтобы она отвечала исключительно за контент и не накладывала никаких ограничений на UI, поэтому при выборе мы ориентировались на Headless CMS. Headless CMS — это системы, которые предоставляют функционал администрирования контента (админка) и генерируют API, к которому можно подключить любой клиент (веб-приложение, мобильное приложение и т.д.). Поскольку Headless CMS — это просто API, они никак не ограничивают клиент и не влияют на реализацию других сервисов бэкенда.
Мы выбрали Strapi, поскольку это одно из самых популярных на сегодняшний день решений. Система является опенсорсной, и, несмотря на то, что у нее есть и платная версия, бесплатного функционала вполне хватает в работе. Мы используем версию Community Edition (62k+ звездочек и почти 7k форков на github). В этой статье я поделюсь нашим опытом работы со Strapi и наглядно опишу, как с его помощью можно решать практические задачи.
Оглавление:
Что умеет Strapi
С помощью Strapi разработчик может назначать коллекции данных, которые будут доступны с помощью API из CMS, а также задавать им конкретную структуру. Strapi автоматически генерирует весь бойлерплейт для совершения CRUD-операций с этими коллекциями данных, а именно:
создает коллекции в БД;
формирует REST API (или GraphQL) для этих коллекций;
позволяет настраивать ограничения на обращение к эндпоинтам;
дает возможность изменять логику на любом уровне с помощью программирования (кастомизировать или добавлять новые эндпоинты, изменять обращения к базе и т.д.).
После того, как разработчики настроят структуру данных, пользователи CMS (например, контент-менеджеры) смогут заполнять коллекции контентом. Затем эти данные можно будет запросить через API.
Последовательность работы со Strapi можно представить в виде следующей схемы:
Схема работы Strapi
Как создать проект на Strapi
Чтобы создать новый проект, нужно выполнить следующую команду:
yarn create strapi-app kts-strapi-project --quickstart
У вас получится проект со следующей структурой:
Cтруктура проекта после создания
В следующих папках будет расположена ключевая логика для работы со Strapi и коллекциями данных:
src/api
— здесь будет храниться структура коллекций в виде json-файлов (иначе говоря — схемы), а также сгенерированные js-файлы с кодом логики этих коллекций;src/components
— здесь хранятся схемы компонентов. Это утилитарные сущности, и для них, в отличие от коллекций, не генерируется API. Компонент можно подключить в качестве поля другого компонента или коллекции. Когда вы запросите у API коллекцию, вы получите связанные с ней компоненты;src/extensions
— здесь хранятся расширения, добавленные для удобства работы с CMS. Вы можете написать свое расширение или подключить его из магазина расширений Strapi.
Чтобы запустить проект, выполните следующую команду:
yarn develop
Система предложит зарегистрироваться. После регистрации вы сможете авторизоваться в ней под созданными учетными данными:
Регистрация в CMS
Режимы работы Strapi
В Strapi есть 2 режима: Content-Type Builder и Content Manager.
Content-Type Builder доступен только в dev-режиме. Dev-режим — это запущенный локально сервер Strapi. На нем разработчики настраивают структуру коллекций, определяют, какими свойствами и атрибутами (иначе говоря, полями) они обладают. После сохранения коллекции происходит кодогенерация json-схем, миграций для БД и бойлерплейта сервера.
Режим Content-Type Builder
Content Manager доступен и в dev-, и в prod-режимах. Prod-режим — это режим, в котором нельзя изменять структуру коллекций, он существует для заполнений коллекций данными.
Режим Content Manager
Как создавать коллекции в Strapi
Для наглядности рассмотрим работу Strapi на примере проекта личного кабинета студента университета. Сначала мы будем создавать коллекции с минимальным набором свойств, а затем дополнять их в процессе знакомства с возможностями Strapi. Первым делом введем некоторые коллекции, которые будут использоваться на нашем сайте.
Коллекция «Студент» со следующими полями:
Коллекция «Специальность» со следующими полями:
название специальности;
код специальности;
длительность обучения (в годах).
Виды коллекций данных
В Strapi есть три вида коллекций — Single Type, Collection Types и Component.
Каждый из них подходит для определенной цели.
Single Type подходит, если вы уверены, что данная коллекция хранится в единственном экземпляре. Например, вам наверняка понадобятся такие коллекции single type:
Documents — коллекция документов в приложении со следующими полями:
user_agreement;
privacy_policy;
other;
Main Page — коллекция для главной страницы приложения;
Header — коллекция для управления пунктами главного меню;
Footer.
Collection Types подойдет, если коллекция представляет собой список данных.
Например, это могут быть:
Components — это вспомогательные сущности. Их нельзя запросить из API отдельным списком, но можно подключить в качестве поля для Single Type, Collection Type или в другой компонент. Примерами Components могут быть:
ссылка, которая состоит из текста для ссылки и ее url-адреса;
карточка, которая состоит из заголовка, изображения и описания;
координата, которая состоит из широты и долготы.
Типы данных
Создадим две коллекции вида Collections Types: «Студент» и «Специальность».
При создании нужно указать Display Name (отображаемое название коллекции в Strapi), а также API ID в единственном и множественном числах — они будут использоваться в дальнейшем для совершения CRUD-операций с коллекциями.
Создание Collection Type «Студент»
При создании коллекции необходимо присвоить тип каждому свойству:
Text — строка;
Rich text (blocks) — текст с форматированием (можно сделать полужирным, курсивным и т.д.);
Number — число;
Date — дата в формате date, datetime или time;
Media — изображение или видео в json-формате, хранит ссылку на файл в хранилище S3;
Relation — тип данных «связь». Нужен для того, чтобы задать связи между коллекциями (но не компонентами). Например, на каждой специальности учится много студентов, поэтому у студента будет поле Relation с ссылкой на Специальность с типом связи «один-ко-многим»;
Boolean — логический тип данных;
JSON — данные в формате JSON;
Email — соответствует строке, но валидируется на формат адреса электронной почты в рамках Strapi;
Password — нельзя запросить из API, но можно использовать, если кастомизировать запросы. Мы пока не нашли применение для этого типа данных, поскольку хранить пароли в БД — не лучшая идея;
Enumeration — выбор из ограниченного списка текстовых значений;
UID — на клиент приходит как строка, но на стороне Strapi происходит проверка на уникальность;
Component — переиспользуемый компонент из коллекции Components;
Dynamic Zone: предположим, у нас есть сущность «Статья», которая состоит из заголовка и контента. Контент, в свою очередь, является массивом произвольных компонентов из определенного набора (например, картинка, текст, видео и опрос). Dynamic Zone позволяет формировать такие динамические списки компонентов.
Типы данных у свойств при создании коллекции
Пример создания коллекции
Создадим коллекцию «Студент» со структурой, которую мы спроектировали выше — добавим поля «имя», «фамилия» и «дата рождения»:
Структура Collection Type «Студент»
Далее по аналогии создадим коллекцию «Специальность»:
Структура Collection Type «Специальность»
Далее необходимо добавить связь для коллекций «Студент» и «Специальность», чтобы из API можно было запросить всех студентов с конкретной специальности или узнать специальность, на которой обучается отдельный студент. Для этого для коллекции «Студент» добавим поле «speciality» с типом Relation. На каждой специальности учится много студентов, поэтому связь будет «один-ко-многим».
Сначала добавим поле «speciality» типа Relation:
Добавление поля «speciality»
Для коллекции «Студент» добавим поле «photo» с типом Media. Стоит отметить, что при добавлении поля типа Media можно настроить ограничения на форматы данных. По умолчанию там любые файлы. Можно ограничить: только изображения, только видео или только pdf:
Структура Collection Type «Студент» после добавления поля «speciality»
Предположим, что у каждой специальности есть ссылка на сайт с её детальным описанием. Давайте добавим поле «link» в коллекцию специальность. Ссылка — это комбинация заголовка и адреса. Как и обсуждали ранее, ссылку нужно сделать именно компонентом, потому что она не существует сама по себе, и её не нужно получать из API. В коллекцию «Специальность» нам также необходимо добавить поле «speciality_link». Для этого мы заранее создадим соответствующий компонент «Ссылка», который будет состоять из названия и заголовка:
Создание компонента «Ссылка»
Структура компонента «Ссылка»
Далее переиспользуем созданный компонент в коллекции «Специальность»:
Переиспользование компонента «Ссылка» внутри Collection Type «Специальность»
Кодогенерация
После сохранения каждой из коллекций происходит автоматическая кодогенерация схем. По сути, код генерируется согласно принципам архитектуры Model-Routes-Controllers-Service, только вместо Model директория называется content-types. Подробнее об этой архитектуре можно почитать здесь.
Также генерируется логика получения коллекций, и все это хранится в следующих папках:
content-types: здесь хранится схема коллекции, которая приходит с API;
controllers: здесь хранится логика, с которой обрабатывается HTTP-запрос при обращении к API: происходит валидация и парсинг параметров запроса, настраивается структура ответа с сервера. Controllers используют внутри себя services, чтобы обращаться к БД. Controllers, в отличие от services, не могут быть переиспользованы;
routes: здесь хранится список эндпоинтов данной коллекции, которые доступны для обращения;
services: здесь настраивается логика обращения к БД. Также именно в services должна быть написана какая-то специфичная бизнес-логика для приложения: например, отправка письма на почту пользователю (это легко сделать с помощью плагинов). Services используются внутри controllers, а также могут переиспользовать друг друга.
Автосгенерированные файлы у коллекции «Студент»
аВ результате получаются следующие файлы:
файлы speciality:
файлы student:
Стоит отметить, что после создания коллекции ее Singular ID и Plural ID нельзя поменять из интерфейса — их можно изменить только путем редактирования кода в файлах выше, но такое изменение часто приводит к ошибкам в миграциях. Если вам все же нужно поменять идентификаторы для коллекции, то самый надежный способ — удалить ее и создать заново.
Пример Singular Id и Plural ID у коллекции «Студент»
Как работать с API
Когда код сгенерирован, можно переходить к работе с API и наполнению коллекций контентом. В этом разделе мы продолжим рассматривать функционал Strapi на примере с проектом личного кабинета студента.
Плагин для автогенерации swagger
Strapi дает возможность устанавливать плагины, которые упрощают настройку и тестирование коллекций. Один из таких плагинов — strapi documentation — позволяет получить автогенерируемый swagger. Чтобы установить его, нужно выполнить следующую команду:
yarn strapi install documentation
После установки документация будет доступна по адресу:
http://localhost:{PORT}/documentation/v1.0.0.
Swagger проекта
Добавление данных в коллекцию
Через режим Content Manager создадим несколько экземпляров коллекции «Студент» и заполним их данными. Здесь важно оговориться, что по умолчанию экземпляр коллекции создается в состоянии Draft — это значит, что он не будет доступен с помощью API. Для того, чтобы открыть доступ к созданному экземпляру, нужно перейти в режим его редактирования и нажать Publish.
Добавление данных в коллекцию «Студент»
НаНастройка доступов
По умолчанию Strapi делает все коллекции приватными — получить их можно только с помощью отправки сгенерированного токена в заголовке. Чтобы сделать коллекцию публичной, нужно открыть раздел «Роли» в настройках. Он будет находиться по адресу:
http://localhost:1338/admin/settings/users-permissions/roles.
В этом разделе можно выбрать, к каким коллекциям будет доступ у авторизованных и неавторизованных пользователей.
Настройки приватности для HTTP-запросов
Публичные запросы
Укажем, какие типы операций неавторизованный пользователь сможет осуществлять с коллекцией Student. Нам достаточно чтения коллекции и отдельного экземпляра, поэтому отметим флажки find и findOne.
Настройка типов операций у HTTP-запросов
Приватные запросы
Если же мы хотим, чтобы какие-то запросы с API были доступны только авторизованным пользователям, следует создать API Token, который придется отправлять его при каждом запросе. Это можно сделать по адресу:
http://localhost:1338/admin/settings/api-tokens/create.
Создание токена авторизации
Значение сгенерированного токена нужно пробрасывать при каждом запросе в заголовке Authorization
, в swagger это делается через нажатие на кнопку Authorize):
Отправка http-заголовка Authorization внутри swagger
Параметры запроса
Рассмотрим query-параметры для получения коллекции Student. Они делятся на две категории.
Первая категория — те параметры, которые связаны исключительно с пагинацией.
query-параметры запроса у Collection Type «Студент», связанные с пагинацией
Вторая категория — параметры, которые помогают настраивать, какие из полей вы хотите запросить у экземпляров коллекций и их порядок.
query-параметры запроса у Collection Type «Студент», связанные с сортировкой и фильтрами
С параметрами этой категории я предлагаю познакомиться поближе:
sort — значение, по которому нужно отсортировать коллекцию. Принимает на вход название поля и направление сортировки (asc/desc). Есть возможность множественной сортировки;
fields — список примитивных полей, которые нужно получить с API. По умолчанию отдаются все примитивные поля, к которым относятся text, enumeration, rich_text, email, password, date, number, boolean и JSON. Fields позволяет описать только те поля, которые нужно получить;
populate: Relation, Media, Dynamic Zone и Component не являются примитивными полями, поскольку для их запроса необходимо выполнить дополнительные запросы в БД или использовать join. Поэтому Strapi по умолчанию вместо связи отправляет id этой связи. Чтобы запросить поля вложенных сущностей, эти поля нужно перечислить в populate;
filters — правила для фильтрации коллекции. Может принимать значения
$gte
,$equal
и другие.
Подробнее о параметрах можно почитать в документации Strapi.
Пример запроса
Сделаем запрос на список студентов:
http://localhost:1338/api/students
И получим следующий ответ:
Ответ на запрос
{
"data": [
{
"id": 1,
"attributes": {
"name": "Арсений",
"surname": "Ковалев",
"birthday_date": "2004-07-14T20:00:00.000Z",
"createdAt": "2024-07-17T09:16:31.088Z",
"updatedAt": "2024-07-21T15:41:59.989Z",
"publishedAt": "2024-07-17T15:43:49.701Z"
}
}
],
"meta": {
"pagination": {
"page": 1,
"pageSize": 25,
"pageCount": 1,
"total": 1
}
}
}
Обратите внимание: экземпляр нашей коллекции обернут в структуру вида { id, attributes }
. Strapi делает так с каждым экземпляром коллекции, а также со вложенными сущностями, о которых мы говорили выше.
Также можно заметить, что в ответе на запрос мы не получили поля speciality
и image
. Произошло это потому, что они являются вложенными сущностями. Как я уже отметил ранее, по умолчанию Strapi не добавляет их в ответ.
Чтобы получать из API информацию о специальности студента и его фото, необходимо добавить параметр populate
и указать в нем, какие именно свойства вы хотите получить. Здесь можно почитать о том, как корректно описывать populate
.
Добавим в запрос информацию о полях, которые нам нужны, и получим запрос следующего вида:
http://localhost:1338/api/students?populate=speciality,photo
С параметром populate
ответ изменился: теперь в него включены поля speciality
и photo
.
Новый ответ на запрос
{
"data": [
{
"id": 1,
"attributes": {
"name": "Арсений",
"surname": "Ковалев",
"birthday_date": "2004-07-14T20:00:00.000Z",
"createdAt": "2024-07-17T09:16:31.088Z",
"updatedAt": "2024-07-21T20:27:58.544Z",
"publishedAt": "2024-07-17T15:43:49.701Z",
"speciality": {
"data": {
"id": 1,
"attributes": {
"createdAt": "2024-07-17T09:17:03.596Z",
"updatedAt": "2024-07-17T15:17:34.613Z",
"publishedAt": "2024-07-17T14:59:42.231Z",
"name": "Прикладная математика и информатика",
"code": "01.03.02",
"duration": 4
}
}
},
"photo": {
"data": {
"id": 1,
"attributes": {
"name": "2024-06-30 12.45.19.jpg",
"alternativeText": null,
"caption": null,
"width": 960,
"height": 1280,
"formats": {
"thumbnail": {
"name": "thumbnail_2024-06-30 12.45.19.jpg",
"hash": "thumbnail_2024_06_30_12_45_19_9e691ba632",
"ext": ".jpg",
"mime": "image/jpeg",
"path": null,
"width": 117,
"height": 156,
"size": 4.82,
"sizeInBytes": 4815,
"url": "/uploads/thumbnail_2024_06_30_12_45_19_9e691ba632.jpg"
},
"small": {
"name": "small_2024-06-30 12.45.19.jpg",
"hash": "small_2024_06_30_12_45_19_9e691ba632",
"ext": ".jpg",
"mime": "image/jpeg",
"path": null,
"width": 375,
"height": 500,
"size": 32.88,
"sizeInBytes": 32882,
"url": "/uploads/small_2024_06_30_12_45_19_9e691ba632.jpg"
},
"medium": {
"name": "medium_2024-06-30 12.45.19.jpg",
"hash": "medium_2024_06_30_12_45_19_9e691ba632",
"ext": ".jpg",
"mime": "image/jpeg",
"path": null,
"width": 563,
"height": 750,
"size": 68.55,
"sizeInBytes": 68547,
"url": "/uploads/medium_2024_06_30_12_45_19_9e691ba632.jpg"
},
"large": {
"name": "large_2024-06-30 12.45.19.jpg",
"hash": "large_2024_06_30_12_45_19_9e691ba632",
"ext": ".jpg",
"mime": "image/jpeg",
"path": null,
"width": 750,
"height": 1000,
"size": 112.06,
"sizeInBytes": 112055,
"url": "/uploads/large_2024_06_30_12_45_19_9e691ba632.jpg"
}
},
"hash": "2024_06_30_12_45_19_9e691ba632",
"ext": ".jpg",
"mime": "image/jpeg",
"size": 156.6,
"url": "/uploads/2024_06_30_12_45_19_9e691ba632.jpg",
"previewUrl": null,
"provider": "local",
"provider_metadata": null,
"createdAt": "2024-07-21T20:27:55.626Z",
"updatedAt": "2024-07-21T20:27:55.626Z"
}
}
}
}
}
],
"meta": {
"pagination": {
"page": 1,
"pageSize": 25,
"pageCount": 1,
"total": 1
}
}
}
Как можно кастомизировать Strapi
Рассмотрим следующую ситуацию. У каждой специальности есть детальная страница на сайте университета, имеющая примерно следующий url:
https://project-example.ru/speciality/{serial_id}
serial_id — это идентификатор, который автоматически присваивается каждому экземпляру коллекции при его создании. С его помощью Strapi позволяет получать информацию об отдельном экземпляре коллекции. К примеру, получить детальную информацию о специальности можно с помощью следующей команды:
GET https://strapi-example.ru/api/speciality/
При этом для улучшения SEO нужно, чтобы каждая детальная страница специальности имела slug (читабельный url) приблизительно следующего формата:
Для этого Strapi позволяет переписать логику контроллера и роутов, которые отвечают за то, по каким правилам будет выполняться запрос к API. Чтобы сделать это, в режиме Content-Type Builder добавим в структуру коллекции «Специальность» поле slug типа UID. Теперь slug каждой специальности будет уникален:
Добавление поля «slug» для Collection «Специальность»
Затем в режиме Content Manager присвоим каждому экземпляру коллекции свой slug и опубликуем всю коллекцию:
Заполнение поля «slug» для Collection «Специальность»
Теперь нужно переписать логику контроллера, чтобы можно было получать нужный экземпляр коллекции по его slug
. Для этого придется выполнить следующие преобразования для коллекции «Специальность»:
Добавить кастомный роут
/api/specialties/get-by-slug/{slug}
.Добавить в файл
services
функциюfindOneBySlug
, чтобы на уровне Strapi корректно обрабатывался sql-запрос.Доработать логику контроллера, чтобы он корректно доставал из query-параметров
slug
иpopulate
и пробрасывал их в функциюfindOneBySlug
.
Сначала добавим утилиту kts-strapi-project/src/utils/extendCoreRouter.js
, чтобы расширять логику дефолтного роутера:
const extendCoreRouter = (innerRouter, extraRoutes = []) => {
let routes;
return {
get prefix() {
return innerRouter.prefix;
},
get routes() {
if (!routes) routes = [...extraRoutes, ...innerRouter.routes];
return routes;
},
};
};
module.exports = { extendCoreRouter };
Затем доработаем файл kts-strapi-project/src/api/speciality/services/speciality.js
с запросом к БД:
Было
'use strict';
/**
* speciality service
*/
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::speciality.speciality');
Стало
"use strict";
const { createCoreService } = require("@strapi/strapi").factories;
module.exports = createCoreService(
"api::speciality.speciality",
({ strapi }) => ({
async findOneBySlug(slug, { populate }) {
return strapi.db.query("api::speciality.speciality").findOne({
where: {
slug: slug,
},
populate,
});
},
})
);
Аналогичным образом поступим и с другими файлами.
kts-strapi-project/src/api/speciality/controllers/speciality.js
:
Было
'use strict';
/**
* speciality controller
*/
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::speciality.speciality');
Стало
"use strict";
const { createCoreController } = require("@strapi/strapi").factories;
module.exports = createCoreController(
"api::speciality.speciality",
({ strapi }) => ({
async findOneBySlug(ctx) {
const { slug } = ctx.params;
const sanitizedQuery = await this.sanitizeQuery(ctx);
const result = await strapi
.service("api::speciality.speciality")
.findOneBySlug(slug, {
populate: sanitizedQuery.populate,
});
const sanitizedResults = await this.sanitizeOutput(result, ctx);
return this.transformResponse(sanitizedResults);
},
})
);
kts-strapi-project/src/api/speciality/routes/speciality.js:
Было
'use strict';
/**
* speciality router
*/
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::speciality.speciality');
Стало
"use strict";
const { extendCoreRouter } = require("../../../utils/extendCoreRouter");
const { createCoreRouter } = require("@strapi/strapi").factories;
const defaultRouter = createCoreRouter("api::speciality.speciality");
module.exports = extendCoreRouter(defaultRouter, [
{
method: "GET",
path: "/specialties/get-by-slug/:slug",
handler: "speciality.findOneBySlug",
config: {
auth: false,
},
},
]);
Таким образом, мы добавили кастомный эндпоинт /api/specialties/get-by-slug/{slug}
.
В примерах выше используется функция sanitizeQuery
, она используется чтобы очистить параметры запроса от ошибок и небезопасных значений. Подробнее об sanitize можно почитать здесь.
Теперь запросим с помощью slug какой-нибудь экземпляр из коллекции «Специальность». Сделаем следующий запрос:
http://localhost:1338/api/specialties/get-by-slug/prikladnaya-matematika-i-informatika
И получим следующий ответ:
{
"data": {
"id": 1,
"attributes": {
"createdAt": "2024-07-17T09:17:03.596Z",
"updatedAt": "2024-07-23T17:38:09.042Z",
"publishedAt": "2024-07-17T14:59:42.231Z",
"name": "Прикладная математика и информатика",
"code": "01.03.02",
"duration": 4,
"slug": "prikladnaya-matematika-i-informatika"
}
},
"meta": {
}
}
Теперь мы можем легко получить экземпляры коллекции не по serial_id, а по slug.
Однако важно отметить, что Swagger не сможет подтянуть типы принимаемых параметров, несмотря на изменение логики получения. Следовательно, сделать запрос с помощью slug через Swagger будет невозможно:
Пример ошибки валидации в swagger
При этом сделать запрос, к примеру, через postman, будет возможно:
Пример получения специальности с помощью «slug» через Postman
Заключение
В рамках этой статьи мы рассмотрели Strapi в общих чертах и определили, для каких целей он нужен. Создали несколько коллекций и получили их с помощью API и добавили кастомный роут для получения экземпляров коллекции с помощью slug. Однако этим функционал системы, разумеется, не заканчивается.
Если вам не терпится углубиться в подробности, следите за нашими публикациями. В следующей статье мы изучим Strapi более детально и поговорим о том:
как обрабатывать сложные типы данных (rich text и dynamic zone);
как сортировать коллекции по популярности;
как мы типизируем и валидируем коллекции на клиенте с использованием Typescript и zod;
как подключать postgres, s3, minio;
какие данные стоит хранить в Strapi, а какие — в админке;
что ожидается в новой версии Strapi 5.
А чтобы скрасить ожидание, предлагаю вам почитать другие материалы в нашем блоге, не менее полезные для фронтенд-разработчиков: