{"id":352297,"date":"2024-05-20T22:06:00","date_gmt":"2024-05-20T22:06:00","guid":{"rendered":"http:\/\/savepearlharbor.com\/?p=352297"},"modified":"-0001-11-30T00:00:00","modified_gmt":"-0001-11-29T21:00:00","slug":"","status":"publish","type":"post","link":"https:\/\/savepearlharbor.com\/?p=352297","title":{"rendered":"<span>\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435 \u0441\u0442\u0430\u0442\u0438\u0447\u043d\u043e\u0433\u043e \u0431\u043b\u043e\u0433\u0430 \u043d\u0430 Angular \u0438 Contentful<\/span>"},"content":{"rendered":"<div><!--[--><!--]--><\/div>\n<div id=\"post-content-body\">\n<div>\n<div class=\"article-formatted-body article-formatted-body article-formatted-body_version-2\">\n<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w780q1\/getpro\/habr\/upload_files\/e97\/e06\/460\/e97e06460712376610e04f90ebe87479.jpg\" alt=\"\" title=\"\" width=\"1920\" height=\"1468\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/e97\/e06\/460\/e97e06460712376610e04f90ebe87479.jpg\" data-blurred=\"true\"\/><\/figure>\n<p>\u0412 \u043f\u0440\u0435\u0434\u044b\u0434\u0443\u0449\u0435\u0439 \u0447\u0430\u0441\u0442\u0438 &#8212; <a href=\"https:\/\/habr.com\/ru\/articles\/754884\/\" rel=\"noopener noreferrer nofollow\"><strong>\u041a\u0430\u043a \u044f \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0438\u043b \u0431\u043b\u043e\u0433 \u0438\u0437 CakePHP \u0432 Angular<\/strong><\/a>, \u044f \u0434\u0435\u043b\u0438\u043b\u0441\u044f \u0441\u0432\u043e\u0435\u0439 \u0438\u0441\u0442\u043e\u0440\u0438\u0435\u0439 \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u0438 \u0431\u043b\u043e\u0433\u0430 \u0438\u0437 CakePHP \u0432 Angular. \u0412 \u044d\u0442\u043e\u0439 \u0441\u0442\u0430\u0442\u044c\u0435, \u044f \u0445\u043e\u0447\u0443 \u043f\u0440\u043e\u0434\u0435\u043c\u043e\u043d\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0441\u0432\u044f\u0437\u043a\u0443 Angular \u0438 Contentful. \u042f \u043f\u043e \u0448\u0430\u0433\u0430\u043c \u0441\u043e\u0437\u0434\u0430\u043c \u043d\u043e\u0432\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435, \u0434\u043e\u0431\u0430\u0432\u043b\u044e \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u044b\u0435 \u0432\u0435\u043d\u0434\u043e\u0440\u044b, \u0430 \u0442\u0430\u043a\u0436\u0435 \u0440\u0435\u0430\u043b\u0438\u0437\u0443\u044e \u0442\u0440\u0435\u0431\u0443\u0435\u043c\u044b\u0435 \u0441\u043a\u0440\u0438\u043f\u0442\u044b \u0434\u043b\u044f \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u043a\u043e\u043d\u0442\u0435\u043d\u0442\u0430.<\/p>\n<p><a href=\"https:\/\/github.com\/Fafnur\/angular-blog\" rel=\"noopener noreferrer nofollow\"><u>\u0418\u0441\u0445\u043e\u0434\u043d\u044b\u0439 \u043a\u043e\u0434 \u043d\u0430 github<\/u><\/a><\/p>\n<p>\u0414\u0435\u043c\u043e \u043c\u043e\u0436\u043d\u043e \u043f\u043e\u0441\u043c\u043e\u0442\u0440\u0435\u0442\u044c \u043d\u0430 <a href=\"http:\/\/angular-blog.fafn.ru\" rel=\"noopener noreferrer nofollow\">angular-blog.fafn.ru<\/a>.\u00a0<\/p>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/d45\/400\/9cc\/d454009cc92a03160fff98b6f837a292.webp\" alt=\"\u0414\u0435\u043c\u043e\u043d\u0441\u0442\u0440\u0430\u0446\u0438\u044f \u0433\u043e\u0442\u043e\u0432\u043e\u0433\u043e \u043f\u0440\u043e\u0435\u043a\u0442\u0430 Angular &amp; Contentful\" title=\"\u0414\u0435\u043c\u043e\u043d\u0441\u0442\u0440\u0430\u0446\u0438\u044f \u0433\u043e\u0442\u043e\u0432\u043e\u0433\u043e \u043f\u0440\u043e\u0435\u043a\u0442\u0430 Angular &amp; Contentful\" width=\"800\" height=\"451\"\/><\/p>\n<div><figcaption>\u0414\u0435\u043c\u043e\u043d\u0441\u0442\u0440\u0430\u0446\u0438\u044f \u0433\u043e\u0442\u043e\u0432\u043e\u0433\u043e \u043f\u0440\u043e\u0435\u043a\u0442\u0430 Angular &amp; Contentful<\/figcaption><\/div>\n<\/figure>\n<h2>\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0435\u043a\u0442\u0430<\/h2>\n<p>\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u043f\u0440\u043e\u0435\u043a\u0442 \u043c\u043e\u0436\u043d\u043e \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0445 <a href=\"https:\/\/angular.io\/guide\/setup-local\" rel=\"noopener noreferrer nofollow\"><u>angular cli<\/u><\/a>:<\/p>\n<pre><code class=\"bash\">ng new angular-blog<\/code><\/pre>\n<p>\u0414\u0440\u0443\u0433\u043e\u0439 \u043f\u043e\u0434\u0445\u043e\u0434 &#8212; \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c <a href=\"https:\/\/nx.dev\/\" rel=\"noopener noreferrer nofollow\">Nx<\/a>. \u0412 \u043d\u0430\u0441\u0442\u043e\u044f\u0449\u0438\u0439 \u043c\u043e\u043c\u0435\u043d\u0442 Nx \u044d\u0442\u043e \u043d\u0435 \u0442\u043e\u043b\u044c\u043a\u043e monorepo, \u0430 \u0442\u0430\u043a\u0436\u0435 \u043d\u0430\u0431\u043e\u0440 \u0443\u0434\u043e\u0431\u043d\u044b\u0445 \u0443\u0442\u0438\u043b\u0438\u0442 \u0434\u043b\u044f \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u043d\u0430 javascript\/typescript. \u041f\u0440\u0438\u044f\u0442\u043d\u044b\u043c \u0431\u043e\u043d\u0443\u0441\u043e\u043c \u043f\u0440\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0438 Nx \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f <code>jest<\/code> \u0438 <code>cypress<\/code> \u0438\u0437 \u043a\u043e\u0440\u043e\u0431\u043a\u0438.<\/p>\n<p>\u0414\u043b\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u044f \u043d\u043e\u0432\u043e\u0433\u043e <code>workspace<\/code>, \u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u043a\u043e\u043c\u0430\u043d\u0434\u0443:\u00a0<\/p>\n<pre><code class=\"bash\">yarn create nx-workspace --packageManager=yarn<\/code><\/pre>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/6c7\/a74\/37f\/6c7a7437f88a5d3cb9962930fc296969.png\" alt=\"\u0420\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u043a\u043e\u043c\u0430\u043d\u0434\u044b\" title=\"\u0420\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u043a\u043e\u043c\u0430\u043d\u0434\u044b\" width=\"1443\" height=\"376\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/6c7\/a74\/37f\/6c7a7437f88a5d3cb9962930fc296969.png\"\/><\/p>\n<div><figcaption>\u0420\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u043a\u043e\u043c\u0430\u043d\u0434\u044b<\/figcaption><\/div>\n<\/figure>\n<p>\u0414\u0430\u043b\u0435\u0435 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u043f\u0440\u043e\u0439\u0442\u0438 \u0448\u0430\u0433\u0438:<\/p>\n<ul>\n<li>\n<p>workspace \u0438 \u0437\u0430\u0434\u0430\u0442\u044c \u0438\u043c\u044f (<code>angular-blog<\/code>);<\/p>\n<\/li>\n<li>\n<p>angular \u0438 \u0432\u0432\u0435\u0441\u0442\u0438 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f(<code>blog<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u0432\u044b\u0431\u0440\u0430\u0442\u044c \u043f\u0440\u0435\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440 (<code>scss<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u043f\u0440\u043e\u043f\u0443\u0441\u0442\u0438\u0442\u044c <code>e2e<\/code><\/p>\n<\/li>\n<li>\n<p>\u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0440\u043e\u0443\u0442\u0438\u043d\u0433<\/p>\n<\/li>\n<li>\n<p>\u043e\u0442\u043a\u0430\u0437\u0430\u0442\u044c\u0441\u044f \u043e\u0442 cloud;<\/p>\n<\/li>\n<li>\n<p>\u043f\u043e\u0434\u043e\u0436\u0434\u0430\u0442\u044c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443 \u0432\u0435\u043d\u0434\u043e\u0440\u043e\u0432.<\/p>\n<\/li>\n<\/ul>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/128\/c58\/3ed\/128c583edfd4c1229cda259ad404f0fd.png\" width=\"1379\" height=\"732\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/128\/c58\/3ed\/128c583edfd4c1229cda259ad404f0fd.png\"\/><\/figure>\n<p>\u041f\u0440\u043e\u0435\u043a\u0442 \u0441\u043e\u0437\u0434\u0430\u043d \u0438 \u043f\u0435\u0440\u0435\u0445\u043e\u0436\u0443 \u043a \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435.<\/p>\n<pre><code class=\"bash\">cd angular-blog<\/code><\/pre>\n<h3>\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0435\u043a\u0442\u0430<\/h3>\n<p>\u0417\u0430\u0434\u0430\u043c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043f\u0440\u0430\u0432\u0438\u043b \u0434\u043b\u044f workspace \u0432 <code>nx.json<\/code>:<\/p>\n<pre><code class=\"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\" } <\/code><\/pre>\n<p>\u0412 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u043c \u044d\u0442\u043e \u043f\u0440\u0430\u0432\u0438\u043b\u0430 \u0434\u043b\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u043e\u0432 \u0438 \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a.<\/p>\n<p>\u0427\u0442\u043e\u0431\u044b \u0432 \u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u0439 \u043d\u0435 \u043f\u043e\u043f\u0430\u0434\u0430\u043b \u043c\u0443\u0441\u043e\u0440, \u0432 <code>.gitignore<\/code> \u0438\u0441\u043a\u043b\u044e\u0447\u0443 \u0444\u0430\u0439\u043b\u044b \u0438 \u0434\u0438\u0440\u0435\u043a\u0442\u043e\u0440\u0438\u0438:<\/p>\n<pre><code>...  # 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 <\/code><\/pre>\n<p>\u041f\u0440\u043e\u0435\u043a\u0442 \u0434\u043e\u043b\u0436\u0435\u043d \u0432\u044b\u0433\u043b\u044f\u0434\u0435\u0442\u044c \u0441\u0442\u0440\u043e\u0439\u043d\u043e \u0438 \u043e\u043f\u0440\u044f\u0442\u043d\u043e. \u041f\u043e\u044d\u0442\u043e\u043c\u0443 \u0434\u043e\u0431\u0430\u0432\u043b\u044e \u043f\u0430\u0440\u0443 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439 \u0432 <a href=\"https:\/\/eslint.org\/\" rel=\"noopener noreferrer nofollow\">eslint<\/a>:<\/p>\n<pre><code class=\"bash\">yarn add -D eslint-plugin-import eslint-plugin-jsdoc eslint-plugin-prettier eslint-plugin-simple-import-sort<\/code><\/pre>\n<p>\u041f\u043e\u0441\u043b\u0435 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438 \u0432 <code>.eslintrc.json<\/code> \u0432\u0441\u0442\u0430\u0432\u043b\u044f\u044e \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u0430:<\/p>\n<pre><code class=\"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\"]     }   ] } <\/code><\/pre>\n<p>\u0412 <code>.prettierrc<\/code> \u0437\u0430\u0434\u0430\u044e \u043f\u0440\u0435\u0434\u043f\u043e\u0447\u0438\u0442\u0430\u0435\u043c\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f:<\/p>\n<pre><code class=\"json\">{   \"bracketSpacing\": true,   \"printWidth\": 140,   \"semi\": true,   \"singleQuote\": true,   \"tabWidth\": 2,   \"useTabs\": false }<\/code><\/pre>\n<p>\u0427\u0442\u043e\u0431\u044b \u043d\u0435 \u0441\u043b\u0435\u0434\u0438\u0442\u044c \u0437\u0430 \u0447\u0438\u0441\u0442\u043e\u0442\u043e\u0439 \u043a\u043e\u0434\u0430, \u0443\u0441\u0442\u0430\u043d\u0430\u0432\u043b\u0438\u0432\u0430\u044e \u0435\u0449\u0435 \u043f\u0430\u0440\u0443 \u043f\u0430\u043a\u0435\u0442\u043e\u0432:<\/p>\n<pre><code class=\"bash\">yarn add -D husky lint-staged\u00a0<\/code><\/pre>\n<p>\u0414\u043b\u044f husky \u0441\u043e\u0437\u0434\u0430\u044e \u0434\u0432\u0430 \u0445\u0443\u043a\u0430: <code>pre-push<\/code>, <code>pre-commit<\/code>.<\/p>\n<pre><code>#!\/usr\/bin\/env sh . \"$(dirname -- \"$0\")\/_\/husky.sh\"  yarn nx affected:lint yarn nx affected:test<\/code><\/pre>\n<p>\u0422\u0430\u043a\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u044f\u044e \u0432 \u043a\u043e\u0440\u0435\u043d\u044c \u043f\u0440\u043e\u0435\u043a\u0442\u0430 <code>.lintstagedrc.json<\/code> \u0441 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u044b\u043c:<\/p>\n<pre><code class=\"json\">{   \"*\": [\"nx affected:lint --fix --files\", \"nx format:write --files\"] }<\/code><\/pre>\n<p>\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u044e \u043f\u0430\u043a\u0435\u0442 <code>universal<\/code>, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0440\u0435\u0430\u043b\u0438\u0437\u0443\u0435\u0442 SSR \u0432 Angular, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u0438\u0441\u043a\u043e\u0432\u044b\u0435 \u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043c\u043e\u0433\u043b\u0438 \u0438\u043d\u0434\u0435\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0441\u0430\u0439\u0442:<\/p>\n<pre><code class=\"bash\">yarn ng add @nguniversal\/express-engine<\/code><\/pre>\n<p>\u041e\u0442\u043c\u0435\u0447\u0443, \u0447\u0442\u043e \u043f\u0440\u0438 \u0437\u0430\u043f\u0443\u0441\u043a\u0435 ng, Nx \u0431\u0443\u0434\u0435\u0442 \u0442\u0440\u0430\u043d\u0441\u0444\u043e\u0440\u043c\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0438 \u043f\u0440\u0435\u0434\u043b\u0430\u0433\u0430\u0442\u044c \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0432\u0430\u0440\u0438\u0430\u043d\u0442. \u0415\u0441\u043b\u0438 \u043a\u043e\u043c\u0430\u043d\u0434\u0430 \u043f\u0430\u0434\u0430\u0435\u0442, \u0442\u043e \u043d\u0443\u0436\u043d\u043e \u0432 \u043a\u043e\u043d\u0446\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440 &#8212;project=name.<\/p>\n<p>\u0415\u0449\u0435 \u043c\u0435\u043d\u044f \u043d\u0435\u043c\u043d\u043e\u0433\u043e \u043f\u043e\u0434\u0431\u0435\u0448\u0438\u0432\u0430\u0435\u0442 \u0440\u0443\u0441\u0441\u043a\u0438\u0439 \u044f\u0437\u044b\u043a \u0432 \u0448\u0430\u0431\u043b\u043e\u043d\u0430\u0445, \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u044e \u043f\u0430\u043a\u0435\u0442 \u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438:<\/p>\n<pre><code>ng add @angular\/localize<\/code><\/pre>\n<p>\u041d\u0430\u0447\u0438\u043d\u0430\u044f \u0441 15 \u0432\u0435\u0440\u0441\u0438\u0438, \u0432 Angular \u0438\u0437\u043c\u0435\u043d\u0438\u043b\u0441\u044f \u043f\u0440\u043e\u0446\u0435\u0441\u0441 \u0441\u0431\u043e\u0440\u043a\u0438 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0434\u043b\u044f \u0441\u0442\u0430\u0440\u044b\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0441\u043e\u0437\u0434\u0430\u0442\u044c \u0444\u0430\u0439\u043b <code>.browserslistrc<\/code> \u0438 \u0443\u043a\u0430\u0437\u0430\u0442\u044c \u0442\u0440\u0435\u0431\u0443\u0435\u043c\u044b\u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u0430. \u042f \u043e\u0431\u044b\u0447\u043d\u043e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u043f\u043e\u043b\u0438\u0442\u0438\u043a\u0443:<\/p>\n<pre><code>>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<\/code><\/pre>\n<p>\u041d\u0435\u043c\u043d\u043e\u0433\u043e \u0438\u0437\u043c\u0435\u043d\u044e \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e <code>appConfig<\/code> \u0438 \u0441\u043e\u0437\u0434\u0430\u043c \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u0435 \u0444\u0430\u0439\u043b\u044b \u0434\u043b\u044f \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u043d\u043e\u0439 \u0432\u0435\u0440\u0441\u0438\u0438 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f.<\/p>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/830\/457\/df2\/830457df235add9477e5533e2f7e6b47.png\" alt=\"\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u043d\u0430\u044f \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u043e\u0432\" title=\"\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u043d\u0430\u044f \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u043e\u0432\" width=\"538\" height=\"423\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/830\/457\/df2\/830457df235add9477e5533e2f7e6b47.png\"\/><\/p>\n<div><figcaption>\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u043d\u0430\u044f \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u043e\u0432<\/figcaption><\/div>\n<\/figure>\n<p><code>appConfig<\/code>:<\/p>\n<pre><code class=\"typescript\">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],     },   ], }; <\/code><\/pre>\n<p><code>browserConfig<\/code>:<\/p>\n<pre><code class=\"typescript\">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); <\/code><\/pre>\n<p>\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u044e <code>hammerjs<\/code>, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043f\u043e\u0437\u0432\u043e\u043b\u044f\u0435\u0442 \u043e\u0442\u043b\u0430\u0432\u043b\u0438\u0432\u0430\u0442\u044c \u0441\u043e\u0431\u044b\u0442\u0438\u044f \u043d\u0430 \u0441\u043c\u0430\u0440\u0442\u0444\u043e\u043d\u0430\u0445:<\/p>\n<pre><code>yarn add -D hammerjs<\/code><\/pre>\n<p>\u0412 <code>app.config.browser.ts<\/code> \u0434\u043e\u0431\u0430\u0432\u043b\u044e \u0438\u043c\u043f\u043e\u0440\u0442 <code>hammerjs<\/code>.<\/p>\n<p>\u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u044f \u043d\u0435 \u0443\u043c\u0435\u044e \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0442\u044c CLI, \u043d\u043e \u0447\u0442\u043e\u0431\u044b \u043c\u0438\u043d\u0438\u0444\u0438\u0446\u0438\u0440\u043e\u0432\u0430\u0442\u044c HTML \u044f \u0437\u0430\u043f\u0443\u0441\u043a\u0430\u044e\u00a0 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0439 \u0441\u043a\u0440\u0438\u043f\u0442:<\/p>\n<pre><code class=\"typescript\">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); <\/code><\/pre>\n<p>\u0414\u043b\u044f \u0435\u0433\u043e \u0440\u0430\u0431\u043e\u0442\u044b \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c <code>html-minifier<\/code>:<\/p>\n<pre><code class=\"bash\">yarn add -D html-minifier @types\/html-minifier<\/code><\/pre>\n<p>\u0415\u0441\u0442\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043a\u0440\u0443\u0442\u044b\u0445 \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a, \u0443\u043f\u0440\u043e\u0449\u0430\u044e\u0449\u0438\u0445 \u0442\u0435\u0441\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0432 Angular. \u0414\u043e\u0431\u0430\u0432\u043b\u044e \u0438\u0445:<\/p>\n<pre><code class=\"bash\">yarn add -D ts-mockito jasmine-marbles ng-mocks<\/code><\/pre>\n<p>\u0422\u0430\u043a \u043a\u0430\u043a \u0440\u0430\u043d\u043d\u0435\u0440 <code>jest<\/code> \u0438\u0441\u043f\u044b\u0442\u044b\u0432\u0430\u0435\u0442 \u043d\u0435\u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0442\u0440\u0443\u0434\u043d\u043e\u0441\u0442\u0438 \u0441 DOM, \u0442\u043e \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u044e \u043f\u0430\u043a\u0435\u0442:<\/p>\n<pre><code class=\"bash\">yarn add -D\u00a0 jest-environment-jsdom<\/code><\/pre>\n<p>\u0421\u043e\u0437\u0434\u0430\u043c \u0444\u0430\u0439\u043b <code>jest.jsdom.js<\/code>:<\/p>\n<pre><code class=\"javascript\">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;<\/code><\/pre>\n<p>\u0422\u0430\u043a\u0436\u0435 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c <code>jest.preset.js<\/code>:<\/p>\n<pre><code class=\"javascript\">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']}`, }; <\/code><\/pre>\n<p>\u0414\u043b\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f <code>.env<\/code> \u0432 \u043f\u0440\u043e\u0435\u043a\u0442\u0435, \u0434\u043e\u0431\u0430\u0432\u043b\u044e <code>dotenv<\/code>:<\/p>\n<pre><code>yarn add -D dotenv<\/code><\/pre>\n<h2>\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435 \u0448\u0430\u0431\u043b\u043e\u043d\u0430 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f<\/h2>\n<p>\u0422\u0435\u043f\u0435\u0440\u044c \u043f\u0435\u0440\u0435\u0439\u0434\u0443 \u043a \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u043a\u0435 \u0448\u0430\u0431\u043b\u043e\u043d\u0430 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f.<\/p>\n<p>\u0421\u043e\u0437\u0434\u0430\u043c \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0443 <code>ui\/layout<\/code>:<\/p>\n<pre><code class=\"bash\">nx g lib ui\/layout<\/code><\/pre>\n<p>\u0421\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0443\u044e \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 <code>layout<\/code>:<\/p>\n<pre><code>yarn nx g c layout --project=ui-layout<\/code><\/pre>\n<p>\u0420\u0430\u0437\u043c\u0435\u0442\u043a\u0430 \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u044b \u043f\u0440\u0438\u043c\u0435\u0442 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0439 \u0432\u0438\u0434 \u0432 <code>layout.component.ts<\/code>:<\/p>\n<pre><code class=\"xml\">&lt;router-outlet name=\"top\">&lt;\/router-outlet> &lt;header>   &lt;router-outlet name=\"header\">&lt;\/router-outlet> &lt;\/header> &lt;main>   &lt;router-outlet name=\"breadcrumbs\">&lt;\/router-outlet>   &lt;router-outlet>&lt;\/router-outlet> &lt;\/main> &lt;footer>   &lt;router-outlet name=\"footer\">&lt;\/router-outlet> &lt;\/footer> &lt;router-outlet name=\"bottom\">&lt;\/router-outlet><\/code><\/pre>\n<p>\u0414\u043e\u0431\u0430\u0432\u043b\u044e \u043a\u0430\u043f\u043b\u044e \u0441\u0442\u0438\u043b\u0435\u0439 \u0432 <code>layout.component.scss:<\/code><\/p>\n<pre><code class=\"css\">: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%;    &amp; > * {     width: 100%;   } } <\/code><\/pre>\n<p>\u0412 <code>LayoutComponent<\/code> \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u0443\u044e <code>RouterOutlet<\/code>, \u0447\u0442\u043e\u0431\u044b \u0431\u043b\u043e\u043a\u0438 \u0432\u044b\u0432\u043e\u0434\u0438\u043b\u0438\u0441\u044c \u0432 \u043d\u0443\u0436\u043d\u044b\u0445 \u043c\u0435\u0441\u0442\u0430\u0445.<\/p>\n<pre><code class=\"typescript\">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 {}<\/code><\/pre>\n<p>\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e Angular Material, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0438\u0434\u0435\u0430\u043b\u044c\u043d\u043e \u043f\u043e\u0434\u0445\u043e\u0434\u0438\u0442 \u0434\u043b\u044f \u043c\u043e\u0435\u0433\u043e \u043f\u0440\u043e\u0435\u043a\u0442\u0430. <\/p>\n<p>\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u044e \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0438:<\/p>\n<pre><code class=\"bash\">yarn add @angular\/cdk @angular\/material\u00a0<\/code><\/pre>\n<p>\u0412 <code>apps\/blog\/src\/stylesheets<\/code> \u0431\u0443\u0434\u0443 \u0445\u0440\u0430\u043d\u0438\u0442\u044c \u0432\u0441\u0435 \u0444\u0430\u0439\u043b\u044b <code>scss<\/code>. \u0421\u043a\u0430\u0447\u0430\u044e \u0438 \u0437\u0430\u043a\u0438\u043d\u0443 \u0432 \u043f\u0430\u043f\u043a\u0443 <code>normalize.scss<\/code>. <\/p>\n<p>\u0421\u0434\u0435\u043b\u0430\u044e \u043f\u0430\u0440\u0443 \u0430\u043b\u0438\u0430\u0441\u043e\u0432 \u043d\u0430 \u043f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0435 <code>variables.scss<\/code>:<\/p>\n<pre><code class=\"css\">@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);<\/code><\/pre>\n<p>\u0417\u0430\u0434\u0430\u043c \u043d\u0435\u043c\u043d\u043e\u0433\u043e \u0433\u043b\u043e\u0431\u0430\u043b\u044c\u043d\u044b\u0445 \u0441\u0442\u0438\u043b\u0435\u0439 <code>global.scss<\/code>:<\/p>\n<pre><code class=\"css\">@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); }<\/code><\/pre>\n<p>\u0421\u043e\u0437\u0434\u0430\u043c \u0444\u0430\u0439\u043b <code>material-theme.scss<\/code>, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0431\u0443\u0434\u0435\u0442 \u0441\u043e\u0434\u0435\u0440\u0436\u0430\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f:<\/p>\n<pre><code class=\"css\">@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); } <\/code><\/pre>\n<p>\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0443 \u0432\u0441\u0435 \u0432 <code>apps\/blog\/src\/styles.scss<\/code>:<\/p>\n<pre><code class=\"css\">\/* You can add global styles to this file, and also import other style files *\/ @import '.\/stylesheets\/normalize'; @import '.\/stylesheets\/material-theme'; @import '.\/stylesheets\/global';<\/code><\/pre>\n<p>\u041c\u043e\u0436\u043d\u043e \u0437\u0430\u043d\u044f\u0442\u044c\u044f \u0448\u0430\u043f\u043a\u043e\u0439 \u0438 \u043f\u043e\u0434\u0432\u0430\u043b\u043e\u043c.\u00a0\u0414\u043e\u0431\u0430\u0432\u043b\u044e <code>header<\/code>:<\/p>\n<pre><code class=\"bash\">yarn nx g lib ui\/header yarn nx g c header --project=ui-header<\/code><\/pre>\n<p>\u0412 <code>header<\/code> \u0432\u044b\u0432\u0435\u0434\u0443 <code>toolbar<\/code> \u0441 \u043b\u043e\u0433\u043e\u0442\u0438\u043f\u043e\u043c, \u0441\u0441\u044b\u043b\u043a\u0430\u043c\u0438 \u043d\u0430 \u0433\u0438\u0442\u0445\u0430\u0431 \u0438 \u043e \u043f\u0440\u043e\u0435\u043a\u0442\u0435, \u0430 \u0442\u0430\u043a\u0436\u0435 \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u0435\u043c \u0442\u0435\u043c\u044b.\u00a0<\/p>\n<pre><code class=\"xml\">&lt;angular-blog-container>   &lt;mat-toolbar>     &lt;angular-blog-menu>&lt;\/angular-blog-menu>     &lt;angular-blog-logo>&lt;\/angular-blog-logo>     &lt;span>&lt;\/span>     &lt;angular-blog-theme-switcher>&lt;\/angular-blog-theme-switcher>     &lt;angular-blog-about>&lt;\/angular-blog-about>     &lt;angular-blog-github>&lt;\/angular-blog-github>   &lt;\/mat-toolbar> &lt;\/angular-blog-container><\/code><\/pre>\n<p><code>header.component.ts<\/code><\/p>\n<pre><code class=\"typescript\">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 {}<\/code><\/pre>\n<p>\u0418\u0437 \u0432\u0430\u0436\u043d\u043e\u0433\u043e \u0442\u0443\u0442 \u0442\u043e\u043b\u044c\u043a\u043e <code>ThemeSwitcherComponent<\/code> \u0438 <code>MenuComponent<\/code>.<\/p>\n<p><code>MenuComponent<\/code> &#8212; \u044d\u0442\u043e \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435 \u043c\u0435\u043d\u044e, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u043c \u0432\u044b\u0432\u0435\u0434\u0435\u043d \u0441\u043f\u0438\u0441\u043e\u043a \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0439 \u0431\u043b\u043e\u0433\u0430.<\/p>\n<p><code>ThemeSwitcherComponent<\/code> &#8212; \u0441\u0432\u0438\u0442\u0447\u0435\u0440 \u0434\u043b\u044f <code>dark\/light<\/code> \u0442\u0435\u043c\u044b. \u041c\u0435\u0445\u0430\u043d\u0438\u0437\u043c \u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e \u043f\u0440\u043e\u0441\u0442\u043e\u0439: \u043f\u0440\u0438 \u043a\u043b\u0438\u043a\u0435 \u043c\u0435\u043d\u044f\u0435\u0442\u0441\u044f \u0441\u0432\u043e\u0439\u0441\u0442\u0432\u043e \u0443 <code>&lt;html><\/code>. \u0422\u0430\u043a \u043a\u0430\u043a \u0432 \u043f\u0440\u043e\u0435\u043a\u0442\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u043f\u0440\u0435\u0440\u0435\u043d\u0434\u0435\u0440, \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0445\u0440\u0430\u043d\u0438\u0442\u044c\u0441\u044f \u0432 \u043a\u0443\u043a\u0435.<\/p>\n<p>\u0412\u0435\u0440\u0441\u0442\u043a\u0430:<\/p>\n<pre><code class=\"xml\">&lt;button mat-icon-button i18n-aria-label=\"Header|Theme Switcher\" aria-label=\"Toggle dark and light modes\" (click)=\"onToggle()\">   &lt;mat-icon>dark_mode&lt;\/mat-icon> &lt;\/button><\/code><\/pre>\n<p>\u0420\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f \u0441\u0432\u0438\u0442\u0447\u0435\u0440\u0430:<\/p>\n<pre><code class=\"typescript\">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&lt;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&lt;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);   } }<\/code><\/pre>\n<p>\u0414\u043e\u0431\u0430\u0432\u043b\u044e <code>footer<\/code>:<\/p>\n<pre><code class=\"bash\">yarn nx g lib ui\/footer yarn nx g c footer --project=ui-footer<\/code><\/pre>\n<p>\u0412 \u0444\u0443\u0442\u043e\u0440\u0435 \u0432\u044b\u0432\u0435\u0434\u0443 \u043f\u0440\u043e\u0441\u0442\u043e \u0441\u0441\u044b\u043b\u043a\u0438 \u043d\u0430 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432 \u043c\u0430\u0440\u043a\u0435\u0442\u0430\u0445 \u0438 \u043a\u043e\u043f\u0438\u0440\u0430\u0439\u0442.<\/p>\n<p>\u0421\u0442\u043e\u0438\u0442 \u0443\u043f\u043e\u043c\u044f\u043d\u0443\u0442\u044c \u043f\u0440\u043e <code>ContainerComponent<\/code> \u0438 <code>RowComponent&amp;ColumnComponent<\/code>. <\/p>\n<p>\u041a\u043e\u043d\u0442\u0435\u0439\u043d\u0435\u0440 &#8212; \u0431\u043b\u043e\u043a, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0432\u044b\u0440\u0430\u0432\u043d\u0438\u0432\u0430\u0435\u0442 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u043e\u0435 \u043f\u043e \u0446\u0435\u043d\u0442\u0440\u0443.\u00a0<\/p>\n<p>\u0421\u0442\u0440\u043e\u043a\u0430 \u0438 \u043a\u043e\u043b\u043e\u043d\u043a\u0430 &#8212; \u0430\u0432\u0442\u043e\u0440\u0441\u043a\u0430\u044f \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f \u0441\u0435\u0442\u043a\u0438 \u0438\u0437 bootstrap. \u042f \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e \u0440\u0430\u0441\u0441\u043a\u0430\u0437\u044b\u0432\u0430\u043b \u043f\u0440\u043e \u044d\u0442\u0438 \u0440\u0435\u0448\u0435\u043d\u0438\u044f \u043d\u0430 \u043c\u0435\u0434\u0438\u0443\u043c\u0435.<\/p>\n<h2>\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435 \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u044b \u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u043b\u043e\u0433\u0430 \u0432 Contentful<\/h2>\n<p>\u0417\u0430\u0432\u043e\u0436\u0443 \u043d\u043e\u0432\u0443\u044e \u0443\u0447\u0435\u0442\u043a\u0443 \u0432 <a href=\"https:\/\/www.contentful.com\" rel=\"noopener noreferrer nofollow\">Contentful<\/a>.<\/p>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/2c2\/619\/b69\/2c2619b69d299f2b0b84e95bf9781d23.png\" alt=\"\u0413\u043b\u0430\u0432\u043d\u0430\u044f \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0430 \u043b\u0438\u0447\u043d\u043e\u0433\u043e \u043a\u0430\u0431\u0438\u043d\u0435\u0442\u0430 \u0432 Contentful\" title=\"\u0413\u043b\u0430\u0432\u043d\u0430\u044f \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0430 \u043b\u0438\u0447\u043d\u043e\u0433\u043e \u043a\u0430\u0431\u0438\u043d\u0435\u0442\u0430 \u0432 Contentful\" width=\"2560\" height=\"1440\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/2c2\/619\/b69\/2c2619b69d299f2b0b84e95bf9781d23.png\"\/><\/p>\n<div><figcaption>\u0413\u043b\u0430\u0432\u043d\u0430\u044f \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0430 \u043b\u0438\u0447\u043d\u043e\u0433\u043e \u043a\u0430\u0431\u0438\u043d\u0435\u0442\u0430 \u0432 Contentful<\/figcaption><\/div>\n<\/figure>\n<p>Cjp<\/p>\n<p>\u0412 \u0440\u0430\u0437\u0434\u0435\u043b\u0435 <code>Content Model<\/code> \u0441\u043e\u0437\u0434\u0430\u044e \u043d\u043e\u0432\u044b\u0439 <code>Content Type<\/code> &#8212; <code>Author<\/code>.\u00a0<\/p>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/569\/ef8\/f5a\/569ef8f5a954d1a205fa33cc2f41e8ba.png\" alt=\"\u042d\u043a\u0448\u0435\u043d \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f \u043d\u043e\u0432\u043e\u0433\u043e \u0442\u0438\u043f\u0430\" title=\"\u042d\u043a\u0448\u0435\u043d \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f \u043d\u043e\u0432\u043e\u0433\u043e \u0442\u0438\u043f\u0430\" width=\"1080\" height=\"183\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/569\/ef8\/f5a\/569ef8f5a954d1a205fa33cc2f41e8ba.png\"\/><\/p>\n<div><figcaption>\u042d\u043a\u0448\u0435\u043d \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f \u043d\u043e\u0432\u043e\u0433\u043e \u0442\u0438\u043f\u0430<\/figcaption><\/div>\n<\/figure>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/23c\/31a\/62e\/23c31a62e5bc43508a86bfe1f4f033e3.png\" alt=\"\u0424\u043e\u0440\u043c\u0430 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f \u043d\u043e\u0432\u043e\u0433\u043e \u0442\u0438\u043f\u0430\" title=\"\u0424\u043e\u0440\u043c\u0430 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f \u043d\u043e\u0432\u043e\u0433\u043e \u0442\u0438\u043f\u0430\" width=\"1873\" height=\"1242\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/23c\/31a\/62e\/23c31a62e5bc43508a86bfe1f4f033e3.png\"\/><\/p>\n<div><figcaption>\u0424\u043e\u0440\u043c\u0430 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f \u043d\u043e\u0432\u043e\u0433\u043e \u0442\u0438\u043f\u0430<\/figcaption><\/div>\n<\/figure>\n<p>\u0410\u0432\u0442\u043e\u0440 \u0431\u0443\u0434\u0435\u0442 \u043e\u0431\u043b\u0430\u0434\u0430\u0442\u044c \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u043c\u0438 \u0441\u0432\u043e\u0439\u0441\u0442\u0432\u0430\u043c\u0438:<\/p>\n<ul>\n<li>\n<p>\u043f\u043e\u043b\u043d\u044b\u043c \u0438\u043c\u0435\u043d\u0435\u043c (<code>fullName<\/code>);\u00a0<\/p>\n<\/li>\n<li>\n<p>\u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u044b\u043c\u0438 \u0430\u0434\u0440\u0435\u0441\u043e\u043c (<code>email<\/code>);\u00a0<\/p>\n<\/li>\n<li>\n<p>\u0431\u0438\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 (<code>bio<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u0438\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043c (<code>avatar<\/code>).<\/p>\n<\/li>\n<\/ul>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/6c9\/3b8\/826\/6c93b88267dd5a6885f017d5e38dd8b8.png\" alt=\"\u0421\u043e\u0437\u0434\u0430\u043d\u043d\u044b\u0439 \u0442\u0438\u043f Author\" title=\"\u0421\u043e\u0437\u0434\u0430\u043d\u043d\u044b\u0439 \u0442\u0438\u043f Author\" width=\"2557\" height=\"845\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/6c9\/3b8\/826\/6c93b88267dd5a6885f017d5e38dd8b8.png\"\/><\/p>\n<div><figcaption>\u0421\u043e\u0437\u0434\u0430\u043d\u043d\u044b\u0439 \u0442\u0438\u043f Author<\/figcaption><\/div>\n<\/figure>\n<p>\u0414\u043b\u044f \u043e\u0431\u044a\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u0441\u0442\u0430\u0442\u0435\u0439 \u043f\u043e \u0433\u0440\u0443\u043f\u043f\u0430\u043c, \u0434\u043e\u0431\u0430\u0432\u043b\u044e \u0435\u0449\u0435 \u043e\u0434\u0438\u043d \u0442\u0438\u043f &#8212; <code>Category<\/code>. <\/p>\n<p>\u0412 \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0438 \u0431\u0443\u0434\u0435\u0442 \u0432\u0441\u0435\u0433\u043e \u0434\u0432\u0430 \u043f\u043e\u043b\u044f: \u0438\u043c\u044f (<code>name<\/code>) \u0438 \u043f\u0443\u0442\u044c (<code>slug<\/code>).<\/p>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/cbf\/c93\/4c3\/cbfc934c37fa2653c15314d7bdbd431b.png\" alt=\"\u0422\u0438\u043f Category\" title=\"\u0422\u0438\u043f Category\" width=\"2557\" height=\"845\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/cbf\/c93\/4c3\/cbfc934c37fa2653c15314d7bdbd431b.png\"\/><\/p>\n<div><figcaption>\u0422\u0438\u043f Category<\/figcaption><\/div>\n<\/figure>\n<p>\u0417\u0430\u0432\u0435\u0434\u0443 \u0433\u043b\u0430\u0432\u043d\u0443\u044e \u0441\u0443\u0449\u043d\u043e\u0441\u0442\u044c \u0432 \u0431\u043b\u043e\u0433\u0435 &#8212; <code>Post<\/code>. \u041f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0435 \u0441\u0432\u043e\u0439\u0441\u0442\u0432\u0430:<\/p>\n<ul>\n<li>\n<p>\u0434\u0430\u0442\u0430 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f (<code>published<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a (<code>title<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435 (<code>description<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f (<code>category<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u0438\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u00a0 (<code>image<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u0438\u0441\u0445\u043e\u0434\u043d\u044b\u0439 \u043c\u0435\u0434\u0438\u0430 \u0444\u0430\u0439\u043b (<code>imageOriginal<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u0430\u0432\u0442\u043e\u0440 (<code>author<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 (<code>headline<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u0438\u043d\u0442\u0440\u043e (<code>intro<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u043f\u0443\u0442\u044c (<code>slug<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u043e\u0435 (<code>body<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u043e\u0432 (<code>views<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u0432\u0440\u0435\u043c\u044f \u043f\u0440\u043e\u0447\u0442\u0435\u043d\u0438\u044f (<code>readingTime<\/code>);<\/p>\n<\/li>\n<\/ul>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/ba8\/9c2\/5a8\/ba89c25a8763ae12fedbc0638b0be5f7.png\" alt=\"\u0422\u0438\u043f Post\" title=\"\u0422\u0438\u043f Post\" width=\"2546\" height=\"1077\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/ba8\/9c2\/5a8\/ba89c25a8763ae12fedbc0638b0be5f7.png\"\/><\/p>\n<div><figcaption>\u0422\u0438\u043f Post<\/figcaption><\/div>\n<\/figure>\n<p>\u0422\u0435\u043f\u0435\u0440\u044c \u043c\u043e\u0436\u043d\u043e \u0437\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0431\u043b\u043e\u0433 \u043a\u043e\u043d\u0442\u0435\u043d\u0442\u043e\u043c. \u041f\u0435\u0440\u0435\u0445\u043e\u0436\u0443 \u0432 \u0440\u0430\u0437\u0434\u0435\u043b <code>Content<\/code>.<\/p>\n<p>\u0414\u043b\u044f \u043f\u0440\u043e\u0435\u043a\u0442\u0430 \u044f \u0431\u0443\u0434\u0443 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043d\u043e\u0432\u043e\u0441\u0442\u0438 \u0441 \u043b\u0443\u0447\u0448\u0435\u0433\u043e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0433\u043e \u0440\u0435\u0441\u0443\u0440\u0441\u0430 &#8212; <a href=\"https:\/\/panorama.pub\/\" rel=\"noopener noreferrer nofollow\">\u041f\u0430\u043d\u043e\u0440\u0430\u043c\u0430<\/a>.<\/p>\n<p>\u0414\u043e\u0431\u0430\u0432\u043b\u044e \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0430\u0432\u0442\u043e\u0440\u043e\u0432:\u00a0<\/p>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/c5c\/2d6\/ba8\/c5c2d6ba8a3b3acca80340ebe76e92e7.png\" alt=\"\u0421\u043f\u0438\u0441\u043e\u043a \u0430\u0432\u0442\u043e\u0440\u043e\u0432\" title=\"\u0421\u043f\u0438\u0441\u043e\u043a \u0430\u0432\u0442\u043e\u0440\u043e\u0432\" width=\"2555\" height=\"911\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/c5c\/2d6\/ba8\/c5c2d6ba8a3b3acca80340ebe76e92e7.png\"\/><\/p>\n<div><figcaption>\u0421\u043f\u0438\u0441\u043e\u043a \u0430\u0432\u0442\u043e\u0440\u043e\u0432<\/figcaption><\/div>\n<\/figure>\n<p>\u0421\u043e\u0437\u0434\u0430\u043c \u0448\u0435\u0441\u0442\u044c \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0439: \u043f\u043e\u043b\u0438\u0442\u0438\u043a\u0430, \u043e\u0431\u0449\u0435\u0441\u0442\u0432\u043e, \u043d\u0430\u0443\u043a\u0430, \u044d\u043a\u043e\u043d\u043e\u043c\u0438\u043a\u0430, \u0441\u0442\u0430\u0442\u044c\u0438 \u0438 \u043a\u043d\u0438\u0433\u0438.<\/p>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/366\/bd6\/214\/366bd6214476b74870b98efee47e9331.png\" alt=\"\u0421\u043f\u0438\u0441\u043e\u043a \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0439\" title=\"\u0421\u043f\u0438\u0441\u043e\u043a \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0439\" width=\"2551\" height=\"672\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/366\/bd6\/214\/366bd6214476b74870b98efee47e9331.png\"\/><\/p>\n<div><figcaption>\u0421\u043f\u0438\u0441\u043e\u043a \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0439<\/figcaption><\/div>\n<\/figure>\n<p>\u041f\u0435\u0440\u0435\u043d\u0435\u0441\u0443 \u0447\u0443\u0442\u044c \u0431\u043e\u043b\u044c\u0448\u0435 \u0434\u0432\u0430\u0434\u0446\u0430\u0442\u0438 \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u0439, \u0434\u043b\u044f \u0434\u0435\u043c\u043e\u043d\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u0430\u0433\u0438\u043d\u0430\u0446\u0438\u0438.<\/p>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/dfc\/5db\/a2a\/dfc5dba2acddab28019d69c3f43588fc.png\" alt=\"\u0421\u043f\u0438\u0441\u043a\u043e \u0441\u0442\u0430\u0442\u0435\u0439\" title=\"\u0421\u043f\u0438\u0441\u043a\u043e \u0441\u0442\u0430\u0442\u0435\u0439\" width=\"2558\" height=\"1290\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/dfc\/5db\/a2a\/dfc5dba2acddab28019d69c3f43588fc.png\"\/><\/p>\n<div><figcaption>\u0421\u043f\u0438\u0441\u043a\u043e \u0441\u0442\u0430\u0442\u0435\u0439<\/figcaption><\/div>\n<\/figure>\n<h2>\u0418\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u044b \u0434\u043b\u044f \u0440\u0430\u0431\u043e\u0442\u044b \u0441 Contentful<\/h2>\n<p>\u0421\u043e\u0437\u0434\u0430\u043c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u043e\u0432 \u0434\u043b\u044f \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0437 Contentful \u0438 \u0440\u0430\u0437\u043c\u0435\u0449\u0443 \u0438\u0445 \u0432 <code>contentful\/common<\/code>:<\/p>\n<pre><code class=\"bash\">yarn nx g lib contentful\/common<\/code><\/pre>\n<p>\u041a\u0430\u0436\u0434\u044b\u0439 \u044d\u043b\u0435\u043c\u0435\u043d\u0442 \u0432 Contentful \u0432\u044b\u0440\u0430\u0436\u0430\u0435\u0442\u0441\u044f \u0432 <code>sys<\/code>:<\/p>\n<pre><code class=\"typescript\">export interface ContentfulSys {   \/**    * System type (\"link\")    *\/   readonly type: string;    \/**    * Link type (\"ContentType\")    *\/   readonly linkType: string;    \/**    * Name (\"post\" | \"category\" | ...)    *\/   readonly id: string; }<\/code><\/pre>\n<ul>\n<li>\n<p><code>type<\/code> &#8212; \u0442\u0438\u043f;<\/p>\n<\/li>\n<li>\n<p><code>linkType<\/code> &#8212; \u0441\u0441\u044b\u043b\u0430\u0435\u043c\u044b\u0439 \u043e\u0431\u044a\u0435\u043a\u0442;<\/p>\n<\/li>\n<li>\n<p><code>id<\/code> &#8212; \u0438\u043c\u044f.<\/p>\n<\/li>\n<\/ul>\n<p>Content Type \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u043c \u043e\u0431\u0440\u0430\u0437\u043e\u043c:<\/p>\n<pre><code class=\"typescript\">export interface ContentfulEntity&lt;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; }<\/code><\/pre>\n<p>Contentful \u0438\u043c\u0435\u0435\u0442 \u043e\u0434\u0438\u043d \u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0439 \u0442\u0438\u043f &#8212; <code>Asset<\/code>, \u0441\u0443\u0449\u043d\u043e\u0441\u0442\u044c \u0434\u043b\u044f \u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f \u043c\u0435\u0434\u0438\u0430 \u0444\u0430\u0439\u043b\u043e\u0432.<\/p>\n<p>\u042f \u043d\u0435 \u043f\u043b\u0430\u043d\u0438\u0440\u0443\u044e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043d\u0438\u0447\u0435\u0433\u043e \u043a\u0440\u043e\u043c\u0435 \u0438\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0439, \u0442\u043e \u0442\u0438\u043f \u043c\u043e\u0436\u043d\u043e \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0442\u0430\u043a:<\/p>\n<pre><code class=\"typescript\">export type ContentfulAsset = ContentfulEntity&lt;{   \/**    * 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;   }; }>;<\/code><\/pre>\n<p>\u041a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u044f \u0432 API \u0431\u0443\u0434\u0435\u0442 \u043e\u0442\u0434\u0430\u043d\u0430 \u0432 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0435\u043c \u0432\u0438\u0434\u0435:<\/p>\n<pre><code class=\"typescript\">export interface ContentfulCollection&lt;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[];   }; }<\/code><\/pre>\n<p>\u0421\u043e\u0437\u0434\u0430\u043c \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0443 \u0438 \u0440\u0430\u0437\u043c\u0435\u0449\u0443 \u0442\u0430\u043c \u0432\u0441\u0435 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e\u0435 \u0434\u043b\u044f \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u0439:<\/p>\n<pre><code class=\"bash\">yarn nx g lib posts\/common<\/code><\/pre>\n<p>\u041f\u0440\u0438\u043c\u0435\u043d\u044f\u044f \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u044b Contentful, \u043f\u043e\u043b\u0443\u0447\u0443 <code>interface<\/code> \u0438 <code>dto<\/code> \u0434\u043b\u044f \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0439:<\/p>\n<pre><code class=\"typescript\">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&lt;Category>;<\/code><\/pre>\n<p>\u0410\u043d\u0430\u043b\u043e\u0433\u0438\u0447\u043d\u0430\u044f \u0441\u0438\u0442\u0443\u0430\u0446\u0438\u044f \u0438 \u0441 \u0430\u0432\u0442\u043e\u0440\u0430\u043c\u0438:<\/p>\n<pre><code class=\"typescript\">\/**  * 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&lt;   Omit&lt;Author, 'avatar'> &amp; {     \/**      * Avatar asset      *\/     readonly avatar: {       readonly sys: ContentfulSys;     };   } >;<\/code><\/pre>\n<p>\u0421\u0430\u043c\u044b\u043c \u0441\u043b\u043e\u0436\u043d\u043e\u0439 \u0441\u0443\u0449\u043d\u043e\u0441\u0442\u0435\u0439 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u044f \u0441\u043e \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0435\u0439 \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0435\u0439:<\/p>\n<pre><code class=\"typescript\">\/**  * 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&lt;   Omit&lt;Post, 'category' | 'image' | 'author' | 'tags'> &amp; {     \/**      * Category sys link      *\/     readonly category: {       readonly sys: ContentfulSys;     };      \/**      * Image sys link      *\/     readonly image: {       readonly sys: ContentfulSys;     };      \/**      * Author sys link      *\/     readonly author: {       readonly sys: ContentfulSys;     };   } >; <\/code><\/pre>\n<h2>\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435 \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u044b \u0437\u0430\u043f\u0438\u0441\u0438<\/h2>\n<p>\u0421\u043e\u0437\u0434\u0430\u043c \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0443 \u0438 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0434\u043b\u044f \u0432\u044b\u0432\u043e\u0434\u0430 \u043f\u043e\u043b\u043d\u043e\u0439 \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u0438:<\/p>\n<pre><code class=\"bash\">yarn nx g lib posts\/view\/page yarn nx g c post-page --project=posts-view-page<\/code><\/pre>\n<p>\u0412 \u0448\u0430\u0431\u043b\u043e\u043d\u0435 \u043f\u043e\u043a\u0430\u0436\u0443 \u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a, \u0430\u0432\u0442\u043e\u0440\u0430, \u0434\u0430\u0442\u0443 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u044f \u0438 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u043e\u0435 \u0441\u0442\u0430\u0442\u044c\u0438:<\/p>\n<pre><code class=\"xml\">&lt;h1>{{ post.headline }}&lt;\/h1> &lt;p>{{ post.published | date : 'shortDate' }}, {{ post.author.fullName }}&lt;\/p> &lt;div>   &lt;img [src]=\"post.image\" alt=\"\" \/> &lt;\/div> &lt;div [innerHTML]=\"post.body | safeHtml\">&lt;\/div><\/code><\/pre>\n<p>\u0422\u0430\u043a \u043a\u0430\u043a \u0442\u0435\u043a\u0441\u0442 \u0441\u0442\u0430\u0442\u044c\u0438 \u044d\u0442\u043e \u0432\u043d\u0435\u0448\u043d\u0438\u0439 HTML, \u0434\u043e\u0431\u0430\u0432\u043b\u044e <code>pipe<\/code> \u0434\u043b\u044f \u0432\u044b\u0432\u043e\u0434\u0430 \u043a\u043e\u043d\u0442\u0435\u043d\u0442\u0430:<\/p>\n<pre><code class=\"typescript\">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)) ?? '';   } }<\/code><\/pre>\n<p>\u0414\u043b\u044f \u0440\u0430\u0431\u043e\u0442\u043e\u0441\u043f\u043e\u0441\u043e\u0431\u043d\u043e\u0441\u0442\u0438 \u0441\u0441\u044b\u043b\u043e\u043a \u0432 \u0442\u0435\u043b\u0435 \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u0438, \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043a\u0430\u043a-\u0442\u043e \u043d\u0430\u0432\u0435\u0448\u0430\u0442\u044c \u0441\u043e\u0431\u044b\u0442\u0438\u044f \u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u0438. \u0421\u0430\u043c\u044b\u043c \u043f\u0440\u043e\u0441\u0442\u044b\u043c \u0440\u0435\u0448\u0435\u043d\u0438\u0435\u043c \u0431\u0443\u0434\u0435\u0442 \u043f\u0440\u043e\u0441\u0442\u043e \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u043d\u0438\u0435 \u0432\u0441\u0435\u0445 <code>a<\/code>.<\/p>\n<pre><code class=\"typescript\">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')]);       });     });   } } <\/code><\/pre>\n<p>\u042d\u0442\u043e \u043d\u0435 \u043b\u0443\u0447\u0448\u0435\u0435 \u0440\u0435\u0448\u0435\u043d\u0438\u0435, \u043d\u043e \u0434\u043b\u044f \u0442\u0435\u0441\u0442\u043e\u0432\u043e\u0433\u043e \u043f\u0440\u043e\u0435\u043a\u0442\u0430 \u043f\u043e\u0434\u0445\u043e\u0434\u0438\u0442.<\/p>\n<blockquote>\n<p> \u041d\u0430\u043f\u0438\u0448\u0438\u0442\u0435 \u0432 \u043a\u043e\u043c\u043c\u0435\u043d\u0442\u0430\u0440\u0438\u044f\u0445, \u043a\u0430\u043a \u0432\u044b \u0440\u0435\u0448\u0430\u0435\u0442\u0435 \u044d\u0442\u0443 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443.<\/p>\n<\/blockquote>\n<h2>\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u044f \u0441\u043f\u0438\u0441\u043a\u0430 \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u0439<\/h2>\n<p>\u0414\u043e\u0431\u0430\u0432\u043b\u044e \u0441\u043f\u0438\u0441\u043e\u043a \u043f\u0440\u0435\u0432\u044c\u044e \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u0439:<\/p>\n<pre><code class=\"bash\">yarn nx g lib posts\/ui\/list yarn nx g c post-list --project=posts-ui-list<\/code><\/pre>\n<p>\u0421\u043e\u0437\u0434\u0430\u043c \u043a\u0430\u0440\u0442\u043e\u0447\u043a\u0443 \u0434\u043b\u044f \u0441\u0442\u0430\u0442\u044c\u0438:<\/p>\n<pre><code class=\"bash\">yarn nx g lib posts\/ui\/card yarn nx g c post-card --project=posts-ui-card<\/code><\/pre>\n<p>\u0412 \u043f\u0440\u0435\u0432\u044c\u044e \u0432\u044b\u0432\u0435\u0434\u0443 \u0438\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435, \u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a \u0438 \u0438\u043d\u0442\u0440\u043e.<\/p>\n<pre><code class=\"xml\">&lt;mat-card>   &lt;a [routerLink]=\"['\/', post.slug]\" [ngStyle]=\"post.image | backgroundImage\" i18n-aria-label=\"Post Card|Image\" aria-label=\"Open full post\">   &lt;\/a>   &lt;mat-card-header>     &lt;mat-card-title>{{ post.headline }}&lt;\/mat-card-title>   &lt;\/mat-card-header>   &lt;mat-card-content>     &lt;div>{{ post.intro }}&lt;\/div>   &lt;\/mat-card-content>   &lt;mat-card-actions>     &lt;a mat-button i18n=\"Post Card|Read more\" [routerLink]=\"['\/', post.slug]\">Read more&lt;\/a>   &lt;\/mat-card-actions> &lt;\/mat-card><\/code><\/pre>\n<p>\u0414\u043b\u044f \u0442\u043e\u0433\u043e \u0447\u0442\u043e\u0431\u044b \u043d\u0435 \u0434\u0435\u0440\u0433\u0430\u043b\u0430\u0441\u044c \u0432\u0435\u0440\u0441\u0442\u043a\u0430, \u0432\u044b\u0432\u0435\u0434\u0443 \u0438\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e <code>backgroundImage<\/code>.<\/p>\n<pre><code class=\"bash\">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' &amp;&amp; image.length ? { backgroundImage: `url(${image})` } : null;   } }<\/code><\/pre>\n<p>\u0422\u0435\u043f\u0435\u0440\u044c \u0432 \u0441\u043f\u0438\u0441\u043a\u0435 \u0441 \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u044f\u043c\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e \u043a\u0430\u0440\u0442\u043e\u0447\u043a\u0443:<\/p>\n<pre><code class=\"typescript\">&lt;angular-blog-row>   &lt;angular-blog-column web=\"6\" *ngFor=\"let post of posts\">     &lt;angular-blog-post-card [post]=\"post\">&lt;\/angular-blog-post-card>   &lt;\/angular-blog-column> &lt;\/angular-blog-row><\/code><\/pre>\n<p>\u0421\u0442\u0430\u0442\u044c\u0438 \u0432\u044b\u0432\u043e\u0436\u0443 \u0432 \u0432\u0438\u0434\u0435 \u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e \u0441\u043f\u0438\u0441\u043a\u0430 \u0432 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0439 \u0432\u0435\u0440\u0441\u0438\u0438, \u0438 \u0432 \u0432\u0438\u0434\u0435 \u043a\u043e\u043b\u043e\u043d\u043e\u043a \u043d\u0430 \u043f\u043b\u0430\u043d\u0448\u0435\u0442\u0435 \u0438 \u043f\u043a.<\/p>\n<pre><code class=\"typescript\">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[]; }<\/code><\/pre>\n<h2>\u0413\u043b\u0430\u0432\u043d\u0430\u044f \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0430 \u0438 \u0440\u0430\u0437\u0434\u0435\u043b\u044b \u0441 \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u043c\u0438<\/h2>\n<p>\u0413\u043b\u0430\u0432\u043d\u0430\u044f \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0430 \u0438 \u0440\u0430\u0437\u0434\u0435\u043b \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0438 \u0438\u043c\u0435\u044e\u0442 \u043e\u0431\u0449\u0438\u0439 \u0434\u0438\u0437\u0430\u0439\u043d, \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u0434\u043e\u0431\u0430\u0432\u043b\u044e \u043b\u0435\u0439\u0430\u0443\u0442:<\/p>\n<pre><code class=\"bash\">yarn nx g lib posts\/ui\/layout yarn nx g c post-layout --project=posts-ui-layout<\/code><\/pre>\n<p>\u0412 \u043c\u0430\u043a\u0435\u0442\u0435 \u0441\u043e\u0437\u0434\u0430\u043c \u0441\u0435\u0442\u043a\u0443 \u0438\u0437 \u0434\u0432\u0443\u0445 \u043a\u043e\u043b\u043e\u043d\u043e\u043a. \u0412 \u043f\u0435\u0440\u0432\u043e\u0439 \u0431\u0443\u0434\u0435\u0442 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u043e\u0435, \u0432\u043e \u0432\u0442\u043e\u0440\u043e\u0439 \u0441\u043f\u0438\u0441\u043e\u043a \u0440\u0430\u0437\u0434\u0435\u043b\u043e\u0432.<\/p>\n<pre><code class=\"xml\">&lt;angular-blog-container>   &lt;angular-blog-breadcrumbs>&lt;\/angular-blog-breadcrumbs>   &lt;angular-blog-row>     &lt;angular-blog-column tablet=\"8\" web=\"9\">       &lt;router-outlet>&lt;\/router-outlet>     &lt;\/angular-blog-column>     &lt;angular-blog-column tablet=\"4\" web=\"3\" class=\"no-mobile\">       &lt;angular-blog-title i18n=\"Post Layout|Categories\">Categories&lt;\/angular-blog-title>       &lt;angular-blog-post-categories>&lt;\/angular-blog-post-categories>     &lt;\/angular-blog-column>   &lt;\/angular-blog-row> &lt;\/angular-blog-container><\/code><\/pre>\n<p>\u0412 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0439 \u0432\u0435\u0440\u0441\u0438\u0438 \u0441\u043a\u0440\u043e\u044e \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0438 \u0438 \u0432\u044b\u0432\u0435\u0434\u0443 \u0438\u0445 \u0432 \u0448\u0430\u043f\u043a\u0435.<\/p>\n<p>\u041a\u0430\u043a \u044f \u0438 \u0433\u043e\u0432\u043e\u0440\u0438\u043b \u0440\u0430\u043d\u0435\u0435, \u0432 \u0431\u043b\u043e\u0433\u0435 \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u043c\u043d\u043e\u0433\u043e \u0441\u0442\u0430\u0442\u0435\u0439. \u0414\u043e\u0431\u0430\u0432\u043b\u044e \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043f\u0430\u0433\u0438\u043d\u0430\u0446\u0438\u0438:<\/p>\n<pre><code class=\"bash\">yarn nx g lib posts\/ui\/pagination yarn nx g c post-pagination --project=posts-ui-pagination<\/code><\/pre>\n<p>\u0414\u043b\u044f \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f \u0434\u0440\u0443\u0433\u0438\u0445 \u0441\u0442\u0440\u0430\u043d\u0438\u0446 \u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e \u0437\u043d\u0430\u0442\u044c \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0432\u0441\u0435\u0433\u043e \u0438\u0445. <\/p>\n<pre><code class=\"xml\">&lt;ng-container *ngIf=\"links\">   &lt;a mat-raised-button [routerLink]=\"link.route\" *ngFor=\"let link of links\">{{ link.label }}&lt;\/a> &lt;\/ng-container><\/code><\/pre>\n<p>\u041f\u043e\u044d\u0442\u043e\u043c\u0443 \u044f \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0443\u044e \u043e\u0431\u044a\u0435\u043a\u0442 \u0441 \u0434\u0432\u0443\u043c\u044f \u0441\u0432\u043e\u0439\u0441\u0442\u0432\u0430\u043c\u0438: <code>current<\/code> \u0438 <code>total<\/code>. \u0417\u0430\u0442\u0435\u043c \u043f\u0440\u043e\u0441\u0442\u043e \u0446\u0438\u043a\u043b\u043e\u043c \u0441\u043e\u0437\u0434\u0430\u044e \u0442\u0440\u0435\u0431\u0443\u0435\u043c\u044b\u0435 \u0441\u0441\u044b\u043b\u043a\u0438.\u00a0<\/p>\n<pre><code class=\"typescript\">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 &amp;&amp; pagination.total !== 1) {       this.links = Array.from({ length: pagination.total }, (v: unknown, k: number) => {         return {           label: `${k + 1}`,           route: pagination.route === '\/feed' &amp;&amp; k === 0 ? '\/' : k === 0 ? pagination.route : `${pagination.route}\/${k + 1}`,         };       });     }   } }<\/code><\/pre>\n<p>\u0414\u043e\u0431\u0430\u0432\u043b\u044e \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0441\u043e \u0441\u043f\u0438\u0441\u043a\u043e\u043c \u0432\u0441\u0435\u0445 \u0440\u0430\u0437\u0434\u0435\u043b\u043e\u0432:<\/p>\n<pre><code class=\"bash\">yarn nx g lib posts\/ui\/categories yarn nx g c post-categories --project=posts-ui-categories<\/code><\/pre>\n<p>\u0412 \u0448\u0430\u0431\u043b\u043e\u043d\u0435 \u043f\u043e\u043a\u0430\u0436\u0443 \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0438:<\/p>\n<pre><code class=\"xml\">&lt;mat-card>   &lt;mat-card-content>     &lt;angular-blog-categories>&lt;\/angular-blog-categories>   &lt;\/mat-card-content> &lt;\/mat-card><\/code><\/pre>\n<p>\u0412\u044b\u0432\u0435\u0434\u0443 \u0445\u043b\u0435\u0431\u043d\u044b\u0435 \u043a\u0440\u043e\u0448\u043a\u0438, \u0447\u0442\u043e\u0431\u044b \u0443\u043f\u0440\u043e\u0441\u0442\u0438\u0442\u044c \u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u044e:<\/p>\n<pre><code class=\"bash\">yarn nx g lib ui\/breadcrumbs yarn nx g c breadcrumbs --project=ui-breadcrumbs<\/code><\/pre>\n<p>\u0420\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f \u0442\u0440\u0438\u0432\u0438\u0430\u043b\u044c\u043d\u0430:<\/p>\n<pre><code class=\"xml\">&lt;ul *ngIf=\"breadcrumbs.length > 0\">   &lt;li *ngFor=\"let breadcrumb of breadcrumbs\">     &lt;a [routerLink]=\"breadcrumb.route\">{{ breadcrumb.label }}&lt;\/a>   &lt;\/li> &lt;\/ul><\/code><\/pre>\n<p>\u0422\u0430\u043a \u043a\u0430\u043a <code>breadcrumbs<\/code> \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e\u0442\u0441\u044f \u0432 \u043c\u0430\u043a\u0435\u0442\u0435, \u0442\u043e \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u043e\u0434\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f \u043d\u0430 \u0441\u043e\u0431\u044b\u0442\u0438\u0435 \u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f \u043f\u0443\u0442\u0438 \u0438 \u043e\u0431\u043d\u043e\u0432\u043b\u044f\u0442\u044c \u0438\u0445:<\/p>\n<pre><code class=\"typescript\">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();   } }<\/code><\/pre>\n<p>\u0421\u043e\u0437\u0434\u0430\u043c \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0443, \u043a\u043e\u0442\u043e\u0440\u0430\u044f \u0431\u0443\u0434\u0435\u0442 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0434\u043b\u044f \u0433\u043b\u0430\u0432\u043d\u043e\u0439 \u0438 \u0440\u0430\u0437\u0434\u0435\u043b\u0430:<\/p>\n<pre><code class=\"bash\">yarn nx g lib posts\/page yarn nx g c post-page --project=posts-page<\/code><\/pre>\n<p>\u0412 \u0448\u0430\u0431\u043b\u043e\u043d \u0434\u043e\u0431\u0430\u0432\u043b\u044e \u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a, \u0441\u043f\u0438\u0441\u043e\u043a \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u0439 \u0438 \u043f\u0430\u0433\u0438\u043d\u0430\u0446\u0438\u044e.<\/p>\n<pre><code class=\"xml\">&lt;angular-blog-title i18n=\"Post Page|Last posts\">Last posts&lt;\/angular-blog-title> &lt;angular-blog-post-list [posts]=\"posts\">&lt;\/angular-blog-post-list> &lt;angular-blog-post-pagination>&lt;\/angular-blog-post-pagination><\/code><\/pre>\n<h2>\u0421\u043a\u0440\u0438\u043f\u0442 \u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u0431\u043b\u043e\u0433\u0430<\/h2>\n<p>\u041a\u043e\u0433\u0434\u0430 \u0441 \u0432\u0435\u0440\u0441\u0442\u043a\u043e\u0439 \u0437\u0430\u043a\u043e\u043d\u0447\u0435\u043d\u043e, \u043f\u0435\u0440\u0435\u0439\u0434\u0443 \u043a \u043d\u0430\u043f\u0438\u0441\u0430\u043d\u0438\u044e \u0441\u043a\u0440\u0438\u043f\u0442\u0430 \u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u0431\u043b\u043e\u0433\u0430 \u0434\u0430\u043d\u043d\u044b\u043c\u0438.<\/p>\n<p>\u0410\u043b\u0433\u043e\u0440\u0438\u0442\u043c \u0431\u0443\u0434\u0435\u0442 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u043c:<\/p>\n<ul>\n<li>\n<p>\u0421\u043a\u0430\u0447\u0438\u0432\u0430\u043d\u0438\u0435 \u0441\u043f\u0438\u0441\u043a\u0430 \u0440\u0430\u0437\u0434\u0435\u043b\u043e\u0432;<\/p>\n<\/li>\n<li>\n<p>\u041f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435 \u0432\u0441\u0435\u0445 \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u0439 \u0438 \u0433\u0440\u0443\u043f\u043f \u0441\u0442\u0430\u0442\u0435\u0439;<\/p>\n<\/li>\n<li>\n<p>\u0413\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u044f \u043b\u0435\u043d\u0442\u044b \u043d\u043e\u0432\u043e\u0441\u0442\u0435\u0439;<\/p>\n<\/li>\n<li>\n<p>\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435 \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u044b \u0441 \u043f\u043e\u043b\u043d\u044b\u043c \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043c.<\/p>\n<\/li>\n<\/ul>\n<p>\u0414\u043e\u0431\u0430\u0432\u043b\u044e \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0443 \u0434\u043b\u044f \u0443\u0442\u0438\u043b\u0438\u0442:<\/p>\n<pre><code class=\"bash\">yarn nx g lib contentful\/utils<\/code><\/pre>\n<p>\u0424\u0443\u043d\u043a\u0446\u0438\u044f <code>load<\/code>:<\/p>\n<pre><code class=\"typescript\">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']   }&amp;content_type=${payload.contentType}&amp;limit=${payload.limit ?? REQUEST_LIMIT}`;    if (payload.skip) {     path += `&amp;skip=${payload.skip}`;   }   if (payload.category) {     path += `&amp;fields.category.sys.id=${payload.category}`;   }    return path; }  export function request&lt;T extends ContentfulEntity = ContentfulEntity>(url: string): Observable&lt;ContentfulCollection&lt;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&lt;T>);         observer.complete();       });       response.on('error', (error) => {         observer.error(error);         observer.complete();       });     });   }); }  export function load&lt;T extends ContentfulEntity = ContentfulEntity>(payload: RequestParams): Observable&lt;ContentfulCollection&lt;T>> {   const limit = payload.limit ?? REQUEST_LIMIT;    return new Observable((observer) => {     request&lt;T>(getUrl(payload))       .pipe(         switchMap((result) => {           if (limit &lt; result.total) {             let index = 1;             const requests = [];              while (index * limit &lt; result.total) {               requests.push(                 request&lt;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();   }); }<\/code><\/pre>\n<p>\u0421\u0443\u0442\u044c \u0432 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0435\u043c:<\/p>\n<ol>\n<li>\n<p>\u0414\u0435\u043b\u0430\u0435\u043c \u043f\u0435\u0440\u0432\u044b\u0439 \u0437\u0430\u043f\u0440\u043e\u0441.<\/p>\n<\/li>\n<li>\n<p>\u0415\u0441\u043b\u0438 \u0441\u0443\u0449\u043d\u043e\u0441\u0442\u0435\u0439 \u0431\u043e\u043b\u044c\u0448\u0435, \u0442\u043e \u0441\u043e\u0437\u0434\u0430\u0435\u043c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043f\u0430\u0440\u0430\u043b\u043b\u0435\u043b\u044c\u043d\u044b\u0445 \u0440\u0435\u043a\u0432\u0435\u0441\u0442\u043e\u0432 \u043d\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0443 \u0434\u0430\u043d\u043d\u044b\u0445 \u0438 \u0436\u0434\u0435\u043c \u0438\u0445 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f. \u0418\u043d\u0430\u0447\u0435 \u043e\u0431\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u043c \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442.<\/p>\n<\/li>\n<li>\n<p>\u0412\u0441\u0435 \u043e\u0442\u0432\u0435\u0442\u044b \u043e\u0431\u044a\u0435\u0434\u0438\u043d\u044f\u0435\u043c \u0432 \u043e\u0434\u0438\u043d, \u0432 \u0447\u0430\u0441\u0442\u043d\u043e\u0441\u0442\u0438 \u044d\u0442\u043e \u043a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u044f <code>items<\/code> \u0438 <code>includes<\/code>.<\/p>\n<\/li>\n<\/ol>\n<p>\u0414\u043e\u0431\u0430\u0432\u043b\u044e \u043a\u0430\u0441\u0442 <code>DTO<\/code> \u0432 \u043e\u0431\u044b\u0447\u043d\u044b\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b:<\/p>\n<pre><code class=\"typescript\">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&lt;string, ContentfulCategory>,   authors: Record&lt;string, ContentfulAuthor>,   images: Record&lt;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&lt;ContentfulPost>): Post[] {   const categories: Record&lt;string, ContentfulCategory> = {};   const authors: Record&lt;string, ContentfulAuthor> = {};   const images: Record&lt;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)); }<\/code><\/pre>\n<p>\u0421\u043e\u0437\u0434\u0430\u043c \u0443\u0442\u0438\u043b\u0438\u0442\u0443 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u044b \u0441 \u043f\u043e\u043b\u043d\u044b\u043c \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043c:<\/p>\n<pre><code class=\"typescript\">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&lt;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 &lt; 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; } <\/code><\/pre>\n<p>\u0428\u0430\u0431\u043b\u043e\u043d\u044b \u043f\u043e\u0441\u0442\u0430 \u0438 \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0438 \u0432\u044b\u043d\u0435\u0441\u0443 \u0432 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u0435 \u043c\u0435\u0442\u043e\u0434\u044b:<\/p>\n<pre><code class=\"typescript\">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: '\u0411\u043b\u043e\u0433',           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} \u043e\u0442 ${new Date().toLocaleDateString()} - Angular blog',         description: '\u041f\u043e\u0441\u043b\u0435\u0434\u043d\u0438\u0435 \u043d\u043e\u0432\u043e\u0441\u0442\u0438 \u0432 \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0438: ${category.name}',       },       breadcrumbs: [         {           label: '\u0411\u043b\u043e\u0433',           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: '\u041d\u043e\u0432\u043e\u0441\u0442\u0438 \u043e\u0442 ${new Date().toLocaleDateString()} - Angular blog',         description: '\u041f\u043e\u0441\u043b\u0435\u0434\u043d\u0438\u0435 \u043d\u043e\u0432\u043e\u0441\u0442\u0438 \u043d\u0430 \u0441\u0430\u0439\u0442\u0435',       },       breadcrumbs: [         {           label: '\u0411\u043b\u043e\u0433',           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),   }`; } <\/code><\/pre>\n<p>\u0414\u043b\u044f \u0437\u0430\u043f\u0438\u0441\u0438 \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u043e\u0432 \u0432\u043e\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e\u0441\u044c \u044d\u0442\u0438\u043c:<\/p>\n<pre><code class=\"typescript\">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); }<\/code><\/pre>\n<p>\u0412 \u043a\u043e\u043d\u0446\u0435 \u0440\u0435\u0430\u043b\u0438\u0437\u0443\u044e \u0444\u0443\u043d\u043a\u0446\u0438\u044e, \u043a\u043e\u0442\u043e\u0440\u0430\u044f \u0431\u0443\u0434\u0435\u0442 \u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0442\u044c \u0434\u0430\u043d\u043d\u044b\u0435 \u0438 \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u044b:<\/p>\n<pre><code class=\"typescript\">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&lt;string, object> = {};    load&lt;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&lt;ContentfulPost>({ contentType: 'post' })];          if (response.items.length > 0) {           requests.push(             ...response.items.map((item) =>               load&lt;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&lt;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(); }<\/code><\/pre>\n<p>\u0421\u043e\u0437\u0434\u0430\u043c <code>load-content.ts<\/code>, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0431\u0443\u0434\u0435\u0442 \u0432\u044b\u0437\u044b\u0432\u0430\u0442\u044c <code>generate<\/code>:<\/p>\n<pre><code class=\"typescript\">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', });<\/code><\/pre>\n<h2>\u0421\u0431\u043e\u0440\u043a\u0430 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f<\/h2>\n<p>\u0413\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u044f \u0431\u043b\u043e\u0433\u0430 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f \u0437\u0430\u043f\u0443\u0441\u043a\u043e\u043c \u043a\u043e\u043c\u0430\u043d\u0434\u044b:<\/p>\n<pre><code class=\"bash\">yarn ts-node --project=apps\/blog\/tsconfig.contentfull.json apps\/blog\/src\/load-content.ts<\/code><\/pre>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/534\/ab5\/c26\/534ab5c26fb4367b010ba52e9dab4a49.png\" alt=\"\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u044f \u0431\u043b\u043e\u0433\u0430\" title=\"\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u044f \u0431\u043b\u043e\u0433\u0430\" width=\"998\" height=\"110\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/534\/ab5\/c26\/534ab5c26fb4367b010ba52e9dab4a49.png\"\/><\/p>\n<div><figcaption>\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u044f \u0431\u043b\u043e\u0433\u0430<\/figcaption><\/div>\n<\/figure>\n<p>\u0421\u0431\u043e\u0440\u043a\u0430 \u043f\u0440\u043e\u0435\u043a\u0442\u0430:<\/p>\n<pre><code class=\"bash\">yarn nx run blog:prerender:production<\/code><\/pre>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/2e7\/1e8\/832\/2e71e8832e6742cd763909a1cd6b2813.png\" alt=\"\u0420\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 \u0441\u0431\u043e\u0440\u043a\u0438 \u043f\u0440\u043e\u0435\u043a\u0442\u0430\" title=\"\u0420\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 \u0441\u0431\u043e\u0440\u043a\u0438 \u043f\u0440\u043e\u0435\u043a\u0442\u0430\" width=\"1308\" height=\"1324\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/2e7\/1e8\/832\/2e71e8832e6742cd763909a1cd6b2813.png\"\/><\/p>\n<div><figcaption>\u0420\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 \u0441\u0431\u043e\u0440\u043a\u0438 \u043f\u0440\u043e\u0435\u043a\u0442\u0430<\/figcaption><\/div>\n<\/figure>\n<p>\u041c\u043e\u0434\u0438\u0444\u0438\u0446\u0438\u0440\u0443\u0435\u043c HTML:<\/p>\n<pre><code>PROJECT=blog yarn ts-node minifier.ts<\/code><\/pre>\n<p>\u0414\u043b\u044f SSR \u0434\u043e\u0431\u0430\u0432\u043b\u044e \u0440\u0430\u0437\u0434\u0430\u0447\u0443 \u0448\u0430\u0431\u043b\u043e\u043d\u043e\u0432 dark\/light \u0442\u0435\u043c\u044b:<\/p>\n<pre><code>PROJECT=blog yarn ts-node .\/scripts\/light-mode.ts<\/code><\/pre>\n<p>\u0417\u0430\u043f\u0443\u0441\u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0430:<\/p>\n<pre><code>node dist\/apps\/blog\/server\/ru\/main.js<\/code><\/pre>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/553\/b86\/a42\/553b86a4274bbba45d269c2c5573934c.png\" alt=\"\u0420\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 \u0437\u0430\u043f\u0443\u0441\u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0430\" title=\"\u0420\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 \u0437\u0430\u043f\u0443\u0441\u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0430\" width=\"816\" height=\"77\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/553\/b86\/a42\/553b86a4274bbba45d269c2c5573934c.png\"\/><\/p>\n<div><figcaption>\u0420\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 \u0437\u0430\u043f\u0443\u0441\u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0430<\/figcaption><\/div>\n<\/figure>\n<p>\u041e\u0442\u043a\u0440\u043e\u0435\u043c \u0432 \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u0435:<\/p>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w780q1\/getpro\/habr\/upload_files\/8ca\/e96\/c9d\/8cae96c9d0dcbc0f3f84e36e46acdd3d.jpg\" alt=\"\u0418\u0442\u043e\u0433\u043e\u0432\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\" title=\"\u0418\u0442\u043e\u0433\u043e\u0432\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\" width=\"1920\" height=\"1080\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/8ca\/e96\/c9d\/8cae96c9d0dcbc0f3f84e36e46acdd3d.jpg\" data-blurred=\"true\"\/><\/p>\n<div><figcaption>\u0418\u0442\u043e\u0433\u043e\u0432\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435<\/figcaption><\/div>\n<\/figure>\n<p>\u0412 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0439 \u0432\u0435\u0440\u0441\u0438\u0438:<\/p>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/245\/7d2\/dec\/2457d2dec9e0d686231f2b8ac145465c.png\" alt=\"\u041f\u0440\u0438\u043c\u0435\u0440\u043d\u043e\u0435 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435 \u0432 iPhone 12 Pro\" title=\"\u041f\u0440\u0438\u043c\u0435\u0440\u043d\u043e\u0435 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435 \u0432 iPhone 12 Pro\" width=\"718\" height=\"1136\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/245\/7d2\/dec\/2457d2dec9e0d686231f2b8ac145465c.png\"\/><\/p>\n<div><figcaption>\u041f\u0440\u0438\u043c\u0435\u0440\u043d\u043e\u0435 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435 \u0432 iPhone 12 Pro<\/figcaption><\/div>\n<\/figure>\n<h2>\u0420\u0435\u0437\u044e\u043c\u0435<\/h2>\n<p>\u0422\u0440\u0443\u0434\u043d\u043e \u043d\u0430\u043f\u0438\u0441\u0430\u0442\u044c \u0432\u0435\u0441\u0435\u043b\u044b\u0439 \u0442\u0443\u0442\u043e\u0440\u0438\u0430\u043b. \u041a\u0440\u0430\u0442\u043a\u043e \u0438\u0437\u043b\u043e\u0436\u0443 \u043f\u0440\u043e\u0434\u0435\u043b\u0430\u043d\u043d\u0443\u044e \u0440\u0430\u0431\u043e\u0442\u0443.<\/p>\n<ol>\n<li>\n<p>\u0421\u043d\u0430\u0447\u0430\u043b\u0430 \u0441\u043e\u0437\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0441\u043e \u0432\u0441\u044f\u043a\u043e\u0439 \u0432\u043a\u0443\u0441\u043e\u0432\u0449\u0438\u043d\u043e\u0439.<\/p>\n<\/li>\n<li>\n<p>\u041f\u043e\u0442\u043e\u043c \u0434\u043e\u0431\u0430\u0432\u043b\u044f\u044e\u0442\u0441\u044f \u0434\u0432\u0435 \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u044b. \u041e\u0434\u043d\u0430 \u0434\u043b\u044f\u00a0\u0432\u044b\u0432\u043e\u0434\u0430 \u0441\u043f\u0438\u0441\u043a\u0430 \u043c\u0430\u0442\u0435\u0440\u0438\u0430\u043b\u043e\u0432, \u0432\u0442\u043e\u0440\u0430\u044f \u0434\u043b\u044f\u00a0\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u043d\u043e\u0439 \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u0438.<\/p>\n<\/li>\n<li>\n<p>\u041f\u043e\u0441\u043b\u0435 \u0432\u00a0Contentful \u0437\u0430\u0432\u043e\u0434\u0438\u0442\u0441\u044f \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u0431\u043b\u043e\u0433\u0430, \u0433\u0434\u0435 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u044f\u044e\u0442\u0441\u044f \u0441\u0443\u0449\u043d\u043e\u0441\u0442\u0438 \u0438 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u0435 \u043c\u0430\u0442\u0435\u0440\u0438\u0430\u043b\u044b.<\/p>\n<\/li>\n<li>\n<p>\u0412\u00a0\u043a\u043e\u043d\u0446\u0435 \u0438\u0434\u0435\u0442 \u043d\u0430\u043f\u0438\u0441\u0430\u043d\u0438\u0435 \u0441\u043a\u0440\u0438\u043f\u0442\u0430, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0432\u044b\u0433\u0440\u0435\u0431\u0430\u0435\u0442 \u0438\u0437\u00a0Contentful \u0432\u0441\u0435 \u0441\u0442\u0430\u0442\u044c\u0438 \u0438 \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0443\u0435\u0442 \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u044b \u0434\u043b\u044f\u00a0Angular.<\/p>\n<\/li>\n<\/ol>\n<p>\u0424\u0438\u0447\u0438, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0435\u0441\u0442\u044c \u0432\u00a0\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0438, \u043d\u043e\u00a0\u043e\u043f\u0443\u0449\u0435\u043d\u044b \u0432\u00a0\u0440\u0430\u043c\u043a\u0430\u0445 \u0441\u0442\u0430\u0442\u044c\u0438:<\/p>\n<ul>\n<li>\n<p>\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 SSR \u0438 \u043f\u0440\u0435\u0440\u0435\u043d\u0434\u0435\u0440\u0430;<\/p>\n<\/li>\n<li>\n<p>\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435 \u043a\u0430\u0440\u0442\u044b \u0441\u0430\u0439\u0442\u0430;<\/p>\n<\/li>\n<li>\n<p>\u0414\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u044f \u043c\u0435\u0442\u0430 \u0442\u0435\u0433\u043e\u0432 \u0434\u043b\u044f\u00a0SEO;<\/p>\n<\/li>\n<li>\n<p>\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438;<\/p>\n<\/li>\n<li>\n<p>\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0442\u0435\u043c (\u0441\u0432\u0435\u0442\u043b\u0430\u044f\/\u0442\u0435\u043c\u043d\u0430\u044f);<\/p>\n<\/li>\n<li>\n<p>\u041e\u043f\u0442\u0438\u043c\u0438\u0437\u0430\u0446\u0438\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0440\u043e\u0443\u0442\u043e\u0432 \u0434\u043b\u044f\u00a0\u0443\u0441\u043a\u043e\u0440\u0435\u043d\u0438\u044f \u0440\u0430\u0431\u043e\u0442\u044b Angular router.<\/p>\n<\/li>\n<\/ul>\n<p>\u0418\u0441\u0445\u043e\u0434\u043d\u044b\u0439 \u043a\u043e\u0434 \u043d\u0430\u00a0github \u043f\u043e\u043c\u043e\u0436\u0435\u0442 \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u0438\u0442\u044c\u0441\u044f \u0441\u00a0\u043f\u0440\u043e\u0435\u043a\u0442\u043e\u043c\u00a0\u2014 <a href=\"https:\/\/github.com\/Fafnur\/angular-blog\" rel=\"noopener noreferrer nofollow\">https:\/\/github.com\/Fafnur\/angular\u2011blog<\/a>.<\/p>\n<\/p>\n<\/div>\n<\/div>\n<\/div>\n<p><!----><!----><\/div>\n<p><!----><!----><br \/> \u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 <a href=\"https:\/\/habr.com\/ru\/articles\/754928\/\"> https:\/\/habr.com\/ru\/articles\/754928\/<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<div><!--[--><!--]--><\/div>\n<div id=\"post-content-body\">\n<div>\n<div class=\"article-formatted-body article-formatted-body article-formatted-body_version-2\">\n<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<figure class=\"full-width\"><\/figure>\n<p>\u0412 \u043f\u0440\u0435\u0434\u044b\u0434\u0443\u0449\u0435\u0439 \u0447\u0430\u0441\u0442\u0438 &#8212; <a href=\"https:\/\/habr.com\/ru\/articles\/754884\/\" rel=\"noopener noreferrer nofollow\"><strong>\u041a\u0430\u043a \u044f \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0438\u043b \u0431\u043b\u043e\u0433 \u0438\u0437 CakePHP \u0432 Angular<\/strong><\/a>, \u044f \u0434\u0435\u043b\u0438\u043b\u0441\u044f \u0441\u0432\u043e\u0435\u0439 \u0438\u0441\u0442\u043e\u0440\u0438\u0435\u0439 \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u0438 \u0431\u043b\u043e\u0433\u0430 \u0438\u0437 CakePHP \u0432 Angular. \u0412 \u044d\u0442\u043e\u0439 \u0441\u0442\u0430\u0442\u044c\u0435, \u044f \u0445\u043e\u0447\u0443 \u043f\u0440\u043e\u0434\u0435\u043c\u043e\u043d\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0441\u0432\u044f\u0437\u043a\u0443 Angular \u0438 Contentful. \u042f \u043f\u043e \u0448\u0430\u0433\u0430\u043c \u0441\u043e\u0437\u0434\u0430\u043c \u043d\u043e\u0432\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435, \u0434\u043e\u0431\u0430\u0432\u043b\u044e \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u044b\u0435 \u0432\u0435\u043d\u0434\u043e\u0440\u044b, \u0430 \u0442\u0430\u043a\u0436\u0435 \u0440\u0435\u0430\u043b\u0438\u0437\u0443\u044e \u0442\u0440\u0435\u0431\u0443\u0435\u043c\u044b\u0435 \u0441\u043a\u0440\u0438\u043f\u0442\u044b \u0434\u043b\u044f \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u043a\u043e\u043d\u0442\u0435\u043d\u0442\u0430.<\/p>\n<p><a href=\"https:\/\/github.com\/Fafnur\/angular-blog\" rel=\"noopener noreferrer nofollow\"><u>\u0418\u0441\u0445\u043e\u0434\u043d\u044b\u0439 \u043a\u043e\u0434 \u043d\u0430 github<\/u><\/a><\/p>\n<p>\u0414\u0435\u043c\u043e \u043c\u043e\u0436\u043d\u043e \u043f\u043e\u0441\u043c\u043e\u0442\u0440\u0435\u0442\u044c \u043d\u0430 <a href=\"http:\/\/angular-blog.fafn.ru\" rel=\"noopener noreferrer nofollow\">angular-blog.fafn.ru<\/a>.\u00a0<\/p>\n<figure class=\"full-width\">\n<div><figcaption>\u0414\u0435\u043c\u043e\u043d\u0441\u0442\u0440\u0430\u0446\u0438\u044f \u0433\u043e\u0442\u043e\u0432\u043e\u0433\u043e \u043f\u0440\u043e\u0435\u043a\u0442\u0430 Angular &amp; Contentful<\/figcaption><\/div>\n<\/figure>\n<h2>\u0421\u043e\u0437\u0434\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0435\u043a\u0442\u0430<\/h2>\n<p>\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u043f\u0440\u043e\u0435\u043a\u0442 \u043c\u043e\u0436\u043d\u043e \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0445 <a href=\"https:\/\/angular.io\/guide\/setup-local\" rel=\"noopener noreferrer nofollow\"><u>angular cli<\/u><\/a>:<\/p>\n<pre><code class=\"bash\">ng new angular-blog<\/code><\/pre>\n<p>\u0414\u0440\u0443\u0433\u043e\u0439 \u043f\u043e\u0434\u0445\u043e\u0434 &#8212; \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c <a href=\"https:\/\/nx.dev\/\" rel=\"noopener noreferrer nofollow\">Nx<\/a>. \u0412 \u043d\u0430\u0441\u0442\u043e\u044f\u0449\u0438\u0439 \u043c\u043e\u043c\u0435\u043d\u0442 Nx \u044d\u0442\u043e \u043d\u0435 \u0442\u043e\u043b\u044c\u043a\u043e monorepo, \u0430 \u0442\u0430\u043a\u0436\u0435 \u043d\u0430\u0431\u043e\u0440 \u0443\u0434\u043e\u0431\u043d\u044b\u0445 \u0443\u0442\u0438\u043b\u0438\u0442 \u0434\u043b\u044f \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u043d\u0430 javascript\/typescript. \u041f\u0440\u0438\u044f\u0442\u043d\u044b\u043c \u0431\u043e\u043d\u0443\u0441\u043e\u043c \u043f\u0440\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0438 Nx \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f <code>jest<\/code> \u0438 <code>cypress<\/code> \u0438\u0437 \u043a\u043e\u0440\u043e\u0431\u043a\u0438.<\/p>\n<p>\u0414\u043b\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u044f \u043d\u043e\u0432\u043e\u0433\u043e <code>workspace<\/code>, \u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u043a\u043e\u043c\u0430\u043d\u0434\u0443:\u00a0<\/p>\n<pre><code class=\"bash\">yarn create nx-workspace --packageManager=yarn<\/code><\/pre>\n<figure class=\"full-width\">\n<div><figcaption>\u0420\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u043a\u043e\u043c\u0430\u043d\u0434\u044b<\/figcaption><\/div>\n<\/figure>\n<p>\u0414\u0430\u043b\u0435\u0435 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u043f\u0440\u043e\u0439\u0442\u0438 \u0448\u0430\u0433\u0438:<\/p>\n<ul>\n<li>\n<p>workspace \u0438 \u0437\u0430\u0434\u0430\u0442\u044c \u0438\u043c\u044f (<code>angular-blog<\/code>);<\/p>\n<\/li>\n<li>\n<p>angular \u0438 \u0432\u0432\u0435\u0441\u0442\u0438 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f(<code>blog<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u0432\u044b\u0431\u0440\u0430\u0442\u044c \u043f\u0440\u0435\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440 (<code>scss<\/code>);<\/p>\n<\/li>\n<li>\n<p>\u043f\u0440\u043e\u043f\u0443\u0441\u0442\u0438\u0442\u044c <code>e2e<\/code><\/p>\n<\/li>\n<li>\n<p>\u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0440\u043e\u0443\u0442\u0438\u043d\u0433<\/p>\n<\/li>\n<li>\n<p>\u043e\u0442\u043a\u0430\u0437\u0430\u0442\u044c\u0441\u044f \u043e\u0442 cloud;<\/p>\n<\/li>\n<li>\n<p>\u043f\u043e\u0434\u043e\u0436\u0434\u0430\u0442\u044c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443 \u0432\u0435\u043d\u0434\u043e\u0440\u043e\u0432.<\/p>\n<\/li>\n<\/ul>\n<figure class=\"full-width\"><\/figure>\n<p>\u041f\u0440\u043e\u0435\u043a\u0442 \u0441\u043e\u0437\u0434\u0430\u043d \u0438 \u043f\u0435\u0440\u0435\u0445\u043e\u0436\u0443 \u043a \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435.<\/p>\n<pre><code class=\"bash\">cd angular-blog<\/code><\/pre>\n<h3>\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0435\u043a\u0442\u0430<\/h3>\n<p>\u0417\u0430\u0434\u0430\u043c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043f\u0440\u0430\u0432\u0438\u043b \u0434\u043b\u044f workspace \u0432 <code>nx.json<\/code>:<\/p>\n<pre><code class=\"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\" } <\/code><\/pre>\n<p>\u0412 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u043c \u044d\u0442\u043e \u043f\u0440\u0430\u0432\u0438\u043b\u0430 \u0434\u043b\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u043e\u0432 \u0438 \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a.<\/p>\n<p>\u0427\u0442\u043e\u0431\u044b \u0432 \u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u0439 \u043d\u0435 \u043f\u043e\u043f\u0430\u0434\u0430\u043b \u043c\u0443\u0441\u043e\u0440, \u0432 <code>.gitignore<\/code> \u0438\u0441\u043a\u043b\u044e\u0447\u0443 \u0444\u0430\u0439\u043b\u044b \u0438 \u0434\u0438\u0440\u0435\u043a\u0442\u043e\u0440\u0438\u0438:<\/p>\n<pre><code>...  # 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 <\/code><\/pre>\n<p>\u041f\u0440\u043e\u0435\u043a\u0442 \u0434\u043e\u043b\u0436\u0435\u043d \u0432\u044b\u0433\u043b\u044f\u0434\u0435\u0442\u044c \u0441\u0442\u0440\u043e\u0439\u043d\u043e \u0438 \u043e\u043f\u0440\u044f\u0442\u043d\u043e. \u041f\u043e\u044d\u0442\u043e\u043c\u0443 \u0434\u043e\u0431\u0430\u0432\u043b\u044e \u043f\u0430\u0440\u0443 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439 \u0432 <a href=\"https:\/\/eslint.org\/\" rel=\"noopener noreferrer nofollow\">eslint<\/a>:<\/p>\n<pre><code class=\"bash\">yarn add -D eslint-plugin-import eslint-plugin-jsdoc eslint-plugin-prettier eslint-plugin-simple-import-sort<\/code><\/pre>\n<p>\u041f\u043e\u0441\u043b\u0435 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438 \u0432 <code>.eslintrc.json<\/code> \u0432\u0441\u0442\u0430\u0432\u043b\u044f\u044e \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u0430:<\/p>\n<pre><code class=\"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\"]     }   ] } <\/code><\/pre>\n<p>\u0412 <code>.prettierrc<\/code> \u0437\u0430\u0434\u0430\u044e \u043f\u0440\u0435\u0434\u043f\u043e\u0447\u0438\u0442\u0430\u0435\u043c\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f:<\/p>\n<pre><code class=\"json\">{   \"bracketSpacing\": true,   \"printWidth\": 140,   \"semi\": true,   \"singleQuote\": true,   \"tabWidth\": 2,   \"useTabs\": false }<\/code><\/pre>\n<p>\u0427\u0442\u043e\u0431\u044b \u043d\u0435 \u0441\u043b\u0435\u0434\u0438\u0442\u044c \u0437\u0430 \u0447\u0438\u0441\u0442\u043e\u0442\u043e\u0439 \u043a\u043e\u0434\u0430, \u0443\u0441\u0442\u0430\u043d\u0430\u0432\u043b\u0438\u0432\u0430\u044e \u0435\u0449\u0435 \u043f\u0430\u0440\u0443 \u043f\u0430\u043a\u0435\u0442\u043e\u0432:<\/p>\n<pre><code class=\"bash\">yarn add -D husky lint-staged\u00a0<\/code><\/pre>\n<p>\u0414\u043b\u044f husky \u0441\u043e\u0437\u0434\u0430\u044e \u0434\u0432\u0430 \u0445\u0443\u043a\u0430: <code>pre-push<\/code>, <code>pre-commit<\/code>.<\/p>\n<pre><code>#!\/usr\/bin\/env sh . \"$(dirname -- \"$0\")\/_\/husky.sh\"  yarn nx affected:lint yarn nx affected:test<\/code><\/pre>\n<p>\u0422\u0430\u043a\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u044f\u044e \u0432 \u043a\u043e\u0440\u0435\u043d\u044c \u043f\u0440\u043e\u0435\u043a\u0442\u0430 <code>.lintstagedrc.json<\/code> \u0441 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u044b\u043c:<\/p>\n<pre><code class=\"json\">{   \"*\": [\"nx affected:lint --fix --files\", \"nx format:write --files\"] }<\/code><\/pre>\n<p>\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u044e \u043f\u0430\u043a\u0435\u0442 <code>universal<\/code>, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0440\u0435\u0430\u043b\u0438\u0437\u0443\u0435\u0442 SSR \u0432 Angular, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u0438\u0441\u043a\u043e\u0432\u044b\u0435 \u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043c\u043e\u0433\u043b\u0438 \u0438\u043d\u0434\u0435\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0441\u0430\u0439\u0442:<\/p>\n<pre><code class=\"bash\">yarn ng add @nguniversal\/express-engine<\/code><\/pre>\n<p>\u041e\u0442\u043c\u0435\u0447\u0443, \u0447\u0442\u043e \u043f\u0440\u0438 \u0437\u0430\u043f\u0443\u0441\u043a\u0435 ng, Nx \u0431\u0443\u0434\u0435\u0442 \u0442\u0440\u0430\u043d\u0441\u0444\u043e\u0440\u043c\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0438 \u043f\u0440\u0435\u0434\u043b\u0430\u0433\u0430\u0442\u044c \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0432\u0430\u0440\u0438\u0430\u043d\u0442. \u0415\u0441\u043b\u0438 \u043a\u043e\u043c\u0430\u043d\u0434\u0430 \u043f\u0430\u0434\u0430\u0435\u0442, \u0442\u043e \u043d\u0443\u0436\u043d\u043e \u0432 \u043a\u043e\u043d\u0446\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440 &#8212;project=name.<\/p>\n<p>\u0415\u0449\u0435 \u043c\u0435\u043d\u044f \u043d\u0435\u043c\u043d\u043e\u0433\u043e \u043f\u043e\u0434\u0431\u0435\u0448\u0438\u0432\u0430\u0435\u0442 \u0440\u0443\u0441\u0441\u043a\u0438\u0439 \u044f\u0437\u044b\u043a \u0432 \u0448\u0430\u0431\u043b\u043e\u043d\u0430\u0445, \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u044e \u043f\u0430\u043a\u0435\u0442 \u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438:<\/p>\n<pre><code>ng add @angular\/localize<\/code><\/pre>\n<p>\u041d\u0430\u0447\u0438\u043d\u0430\u044f \u0441 15 \u0432\u0435\u0440\u0441\u0438\u0438, \u0432 Angular \u0438\u0437\u043c\u0435\u043d\u0438\u043b\u0441\u044f \u043f\u0440\u043e\u0446\u0435\u0441\u0441 \u0441\u0431\u043e\u0440\u043a\u0438 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0434\u043b\u044f \u0441\u0442\u0430\u0440\u044b\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0441\u043e\u0437\u0434\u0430\u0442\u044c \u0444\u0430\u0439\u043b <code>.browserslistrc<\/code> \u0438 \u0443\u043a\u0430\u0437\u0430\u0442\u044c \u0442\u0440\u0435\u0431\u0443\u0435\u043c\u044b\u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u0430. \u042f \u043e\u0431\u044b\u0447\u043d\u043e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u043f\u043e\u043b\u0438\u0442\u0438\u043a\u0443:<\/p>\n<pre><code>>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<\/code><\/pre>\n<p>\u041d\u0435\u043c\u043d\u043e\u0433\u043e \u0438\u0437\u043c\u0435\u043d\u044e \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e <code>appConfig<\/code> \u0438 \u0441\u043e\u0437\u0434\u0430\u043c \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u0435 \u0444\u0430\u0439\u043b\u044b \u0434\u043b\u044f \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u043d\u043e\u0439 \u0432\u0435\u0440\u0441\u0438\u0438 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f.<\/p>\n<figure class=\"full-width\">\n<div><figcaption>\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u043d\u0430\u044f \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u043e\u0432<\/figcaption><\/div>\n<\/figure>\n<p><code>appConfig<\/code>:<\/p>\n<pre><code class=\"typescript\">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],     },   ], }; <\/code><\/pre>\n<p><code>browserConfig<\/code>:<\/p>\n<pre><code class=\"typescript\">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); <\/code><\/pre>\n<p>\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u044e <code>hammerjs<\/code>, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043f\u043e\u0437\u0432\u043e\u043b\u044f\u0435\u0442 \u043e\u0442\u043b\u0430\u0432\u043b\u0438\u0432\u0430\u0442\u044c \u0441\u043e\u0431\u044b\u0442\u0438\u044f \u043d\u0430 \u0441\u043c\u0430\u0440\u0442\u0444\u043e\u043d\u0430\u0445:<\/p>\n<pre><code>yarn <\/code><\/pre>\n<\/div>\n<\/div>\n<\/div>\n<\/div>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[],"tags":[],"class_list":["post-352297","post","type-post","status-publish","format-standard","hentry"],"_links":{"self":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/352297","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=352297"}],"version-history":[{"count":0,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/352297\/revisions"}],"wp:attachment":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=352297"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=352297"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=352297"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}