├── .nvmrc ├── .npmrc ├── src ├── data │ ├── permalink.js │ ├── pageType.js │ ├── env.js │ ├── constants.js │ └── meta.json ├── views │ ├── doc-json.njk │ ├── people-index.njk │ ├── people-info.njk │ ├── person-json.njk │ ├── article-index-json.njk │ ├── featured-json.njk │ ├── all.11tydata.js │ ├── people.11tydata.js │ ├── 404.11tydata.js │ ├── sc-all.11tydata.js │ ├── offline.11tydata.js │ ├── subscribe.11tydata.js │ ├── featured-json.11tydata.js │ ├── sitemap.njk │ ├── people-csv.11tydata.js │ ├── people-info.11tydata.js │ ├── people-csv.njk │ ├── sc-all.njk │ ├── sc.njk │ ├── sc-index.njk │ ├── page.11tydata.js │ ├── specials.11tydata.js │ ├── offline.njk │ ├── sc.11tydata.js │ ├── 404.njk │ ├── feed.11tydata.js │ ├── specials.njk │ ├── people-index.11tydata.js │ ├── page.njk │ ├── feed.njk │ ├── sitemap.11tydata.js │ ├── all.njk │ ├── article-index-json.11tydata.js │ └── sc-index.11tydata.js ├── includes │ ├── blocks │ │ ├── search-hits.njk │ │ ├── snow-25.njk │ │ ├── skip-link.njk │ │ ├── snow.njk │ │ ├── cookie-notification.njk │ │ ├── aside.njk │ │ ├── nav-list.njk │ │ ├── snow-toggle.njk │ │ ├── top-banner.njk │ │ ├── search-category.njk │ │ ├── theme-toggle.njk │ │ ├── person-avatar.njk │ │ ├── search-tags.njk │ │ ├── featured-article.njk │ │ ├── linked-article.njk │ │ ├── article-image.njk │ │ ├── search.njk │ │ ├── logo.njk │ │ ├── baseline.njk │ │ └── person.njk │ ├── analytics │ │ ├── google.njk │ │ └── metrika.njk │ ├── promos │ │ ├── default.njk │ │ ├── stream.njk │ │ └── dream-job.njk │ ├── related-articles-gallery.njk │ ├── articles-gallery.njk │ ├── subscribe-popup.njk │ └── practices.njk ├── favicon.ico ├── images │ ├── covers │ │ └── og.png │ ├── icons │ │ ├── 96x96.png │ │ ├── 144x144.png │ │ ├── 180x180.png │ │ ├── 192x192.png │ │ ├── 256x256.png │ │ ├── 512x512.png │ │ ├── maskable.png │ │ └── icon.svg │ ├── baseline │ │ ├── flag.svg │ │ ├── no.svg │ │ ├── preview.svg │ │ └── chrome.svg │ ├── top-banner │ │ ├── bat-teeth.svg │ │ └── bat-eyes.svg │ ├── partners │ │ └── practicum-icon.svg │ ├── assets │ │ └── cached-link.svg │ ├── publisher-logo.svg │ └── badges │ │ └── most-viewed-month-zeta.svg ├── fonts │ ├── graphik │ │ ├── graphik-medium.woff2 │ │ ├── graphik-regular.woff2 │ │ └── graphik-regular-italic.woff2 │ └── spot-mono │ │ └── spot-mono-light.woff2 ├── styles │ ├── blocks │ │ ├── snow-toggle.css │ │ ├── materials-collection.css │ │ ├── all-articles.css │ │ ├── search-result-list.css │ │ ├── code-fix.css │ │ ├── base-list.css │ │ ├── snow-25.css │ │ ├── person-grid.css │ │ ├── filter-group.css │ │ ├── contributors.css │ │ ├── visually-hidden.css │ │ ├── format-block.css │ │ ├── inline-code.css │ │ ├── related-articles-list.css │ │ ├── container.css │ │ ├── color-picker.css │ │ ├── cookie-notification.css │ │ ├── details.css │ │ ├── hotkey.css │ │ ├── figure.css │ │ ├── persons-list.css │ │ ├── index-group-list.css │ │ ├── search-category.css │ │ ├── table-wrapper.css │ │ ├── feedback-control-list.css │ │ ├── notification.css │ │ ├── person-avatar.css │ │ ├── base.css │ │ ├── snow.css │ │ ├── articles-gallery.css │ │ ├── breadcrumbs.css │ │ ├── search-tag.css │ │ ├── copy-button.css │ │ ├── people-page.css │ │ ├── article-image.css │ │ ├── vote.css │ │ ├── toc.css │ │ ├── standalone-page.css │ │ ├── suggestion-list.css │ │ ├── not-found.css │ │ ├── article-indexes-list.css │ │ ├── featured-articles-list.css │ │ ├── callout.css │ │ ├── person.css │ │ ├── float-button.css │ │ ├── theme-toggle.css │ │ ├── person-links-list.css │ │ ├── button.css │ │ ├── tag-filter.css │ │ ├── header-animation.css │ │ ├── questions.css │ │ ├── switch.css │ │ ├── filter-panel.css │ │ ├── intro.css │ │ ├── top-banner.css │ │ ├── search-hit.css │ │ ├── index-section.css │ │ └── footer.css │ ├── base-colors.css │ ├── dark-theme.css │ ├── light-theme.css │ ├── fonts.sc.css │ └── fonts.css ├── libs │ ├── title-formatter │ │ └── title-formatter.js │ ├── role-constructor │ │ ├── collection.json │ │ └── role-constructor.js │ ├── __tests__ │ │ └── collection-helpers.js │ ├── collection-helpers │ │ └── set-path.js │ └── github-contribution-stats │ │ └── github-contribution-stats.js ├── scripts │ ├── libs │ │ ├── __tests__ │ │ │ ├── example.js │ │ │ ├── toc-text-crop-test.js │ │ │ └── last-update-test.js │ │ ├── debounce.js │ │ └── throttle.js │ ├── core │ │ ├── base-component.js │ │ ├── search-api-client.js │ │ └── search-commons.js │ ├── modules │ │ ├── persons-list.js │ │ ├── transform-article-data.js │ │ ├── article-nav.js │ │ ├── article-aside.js │ │ ├── linked-article-navigation.js │ │ ├── toc-text-crop.js │ │ ├── cookie-notification.js │ │ ├── filter-panel.js │ │ ├── search-page-filter.js │ │ ├── code-line-numbers.js │ │ ├── articles-gallery.js │ │ ├── last-update.js │ │ ├── person-badges-tooltip.js │ │ ├── snow-toggle.js │ │ ├── articles-index.js │ │ ├── top-banner.js │ │ ├── copy-code-snippet.js │ │ ├── header-quick-search-presenter.js │ │ ├── logo.js │ │ ├── form-cache.js │ │ └── people.js │ └── index.js ├── transforms │ ├── link-transform.js │ ├── article-inline-code-transform.js │ ├── table-transform.js │ ├── iframe-attr-transform.js │ ├── details-transform.js │ ├── color-picker-transform.js │ ├── demo-link-transform.js │ ├── headings-id-transform.js │ ├── toc-transform.js │ ├── answers-link-transform.js │ ├── callout-transform.js │ ├── code-classes-transform.js │ ├── demo-external-link-transform.js │ ├── code-breakify-transform.js │ └── image-place-transform.js ├── robots.txt ├── promos │ ├── b-day.md │ ├── doka-dvizh.md │ ├── doka-dvizh-2.md │ ├── oklich-stream.md │ ├── dream-job.md │ ├── pwa-stream.md │ ├── algos-after.md │ ├── dream-job-final.md │ ├── algos.md │ ├── border-job.md │ ├── burnout.md │ ├── interview-promo.md │ ├── oklich-questions.md │ ├── interview-search.md │ └── stream.md ├── layouts │ └── base.njk ├── manifest.json └── markdown-it.js ├── .babelrc ├── CODEOWNERS ├── .prettierrc.json ├── .dockerignore ├── .eslintignore ├── .editorconfig ├── jest.config.js ├── jest.setup.js ├── Dockerfile ├── config ├── env.js ├── category-colors.js └── constants.js ├── .eslintrc.json ├── .gitignore ├── docs ├── README.md ├── deploy.md ├── license.md └── how-to-run.md ├── .github ├── workflows │ ├── update-release.yml │ ├── docker-deploy.yml │ ├── linting.yml │ ├── w3c-validator.yml │ └── product-deploy.yml ├── release.yml └── scripts │ └── update-release.sh ├── .env.example ├── .stylelintrc.json └── LICENSE.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict = true 2 | -------------------------------------------------------------------------------- /src/data/permalink.js: -------------------------------------------------------------------------------- 1 | module.exports = false 2 | -------------------------------------------------------------------------------- /src/data/pageType.js: -------------------------------------------------------------------------------- 1 | module.exports = 'WebPage' 2 | -------------------------------------------------------------------------------- /src/views/doc-json.njk: -------------------------------------------------------------------------------- 1 | {{ docJson | dump | safe }} 2 | -------------------------------------------------------------------------------- /src/views/people-index.njk: -------------------------------------------------------------------------------- 1 | {{ json | dump | safe }} 2 | -------------------------------------------------------------------------------- /src/views/people-info.njk: -------------------------------------------------------------------------------- 1 | {{ json | dump | safe }} 2 | -------------------------------------------------------------------------------- /src/views/person-json.njk: -------------------------------------------------------------------------------- 1 | {{ json | dump | safe }} 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @furtivite @solarrust @igsekor @HellSquirrel 2 | -------------------------------------------------------------------------------- /src/views/article-index-json.njk: -------------------------------------------------------------------------------- 1 | {{ json | dump | safe }} 2 | -------------------------------------------------------------------------------- /src/views/featured-json.njk: -------------------------------------------------------------------------------- 1 | {{ featuredJson | dump | safe }} 2 | -------------------------------------------------------------------------------- /src/includes/blocks/search-hits.njk: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doka-guide/platform/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/data/env.js: -------------------------------------------------------------------------------- 1 | const env = require('../../config/env') 2 | 3 | module.exports = env 4 | -------------------------------------------------------------------------------- /src/includes/blocks/snow-25.njk: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth" : 120, 3 | "semi": false, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /src/images/covers/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doka-guide/platform/HEAD/src/images/covers/og.png -------------------------------------------------------------------------------- /src/images/icons/96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doka-guide/platform/HEAD/src/images/icons/96x96.png -------------------------------------------------------------------------------- /src/data/constants.js: -------------------------------------------------------------------------------- 1 | const constants = require('../../config/constants') 2 | 3 | module.exports = constants 4 | -------------------------------------------------------------------------------- /src/images/icons/144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doka-guide/platform/HEAD/src/images/icons/144x144.png -------------------------------------------------------------------------------- /src/images/icons/180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doka-guide/platform/HEAD/src/images/icons/180x180.png -------------------------------------------------------------------------------- /src/images/icons/192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doka-guide/platform/HEAD/src/images/icons/192x192.png -------------------------------------------------------------------------------- /src/images/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doka-guide/platform/HEAD/src/images/icons/256x256.png -------------------------------------------------------------------------------- /src/images/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doka-guide/platform/HEAD/src/images/icons/512x512.png -------------------------------------------------------------------------------- /src/images/icons/maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doka-guide/platform/HEAD/src/images/icons/maskable.png -------------------------------------------------------------------------------- /src/includes/blocks/skip-link.njk: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/fonts/graphik/graphik-medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doka-guide/platform/HEAD/src/fonts/graphik/graphik-medium.woff2 -------------------------------------------------------------------------------- /src/fonts/graphik/graphik-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doka-guide/platform/HEAD/src/fonts/graphik/graphik-regular.woff2 -------------------------------------------------------------------------------- /src/views/all.11tydata.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: 'Все статьи', 3 | layout: 'base.njk', 4 | permalink: '/all/', 5 | } 6 | -------------------------------------------------------------------------------- /src/fonts/spot-mono/spot-mono-light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doka-guide/platform/HEAD/src/fonts/spot-mono/spot-mono-light.woff2 -------------------------------------------------------------------------------- /src/styles/blocks/snow-toggle.css: -------------------------------------------------------------------------------- 1 | @media not all and (width >= 768px) { 2 | .snow-toggle { 3 | margin-top: 1em; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/views/people.11tydata.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: 'Участники', 3 | layout: 'base.njk', 4 | permalink: '/people/', 5 | } 6 | -------------------------------------------------------------------------------- /src/fonts/graphik/graphik-regular-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doka-guide/platform/HEAD/src/fonts/graphik/graphik-regular-italic.woff2 -------------------------------------------------------------------------------- /src/styles/blocks/materials-collection.css: -------------------------------------------------------------------------------- 1 | .materials-collection { 2 | padding-bottom: 40px; 3 | padding-bottom: clamp(40px, 11%, 180px); 4 | } 5 | -------------------------------------------------------------------------------- /src/views/404.11tydata.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: 'Страница не найдена', 3 | layout: 'base.njk', 4 | permalink: '/404/index.html', 5 | } 6 | -------------------------------------------------------------------------------- /src/views/sc-all.11tydata.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: 'Все статьи', 3 | layout: 'base.njk', 4 | permalink: '/all/index.sc.html', 5 | } 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | docs 4 | 5 | .idea 6 | .git* 7 | .vscode 8 | *.code-workspace 9 | *.sublime-workspace 10 | *.sublime-project -------------------------------------------------------------------------------- /src/includes/blocks/snow.njk: -------------------------------------------------------------------------------- 1 |
2 | {% for i in range(0, 90) -%} 3 |
4 | {%- endfor %} 5 |
6 | -------------------------------------------------------------------------------- /src/views/offline.11tydata.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: 'Вы сейчас не в сети', 3 | layout: 'base.njk', 4 | permalink: '/offline/index.html', 5 | } 6 | -------------------------------------------------------------------------------- /src/styles/blocks/all-articles.css: -------------------------------------------------------------------------------- 1 | .all-articles { 2 | --padding-rule: clamp(40px, 10%, 120px); 3 | padding: var(--padding-rule) 0; 4 | column-width: 300px; 5 | } 6 | -------------------------------------------------------------------------------- /src/views/subscribe.11tydata.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: 'Подписка на рассылку от Доки', 3 | layout: 'base.njk', 4 | permalink: '/subscribe/index.html', 5 | } 6 | -------------------------------------------------------------------------------- /src/data/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Дока", 3 | "description": "Путь в разработку", 4 | "url": "https://github.com/doka-guide/content", 5 | "lang": "ru", 6 | "locale": "ru_ru" 7 | } 8 | -------------------------------------------------------------------------------- /src/styles/blocks/search-result-list.css: -------------------------------------------------------------------------------- 1 | .search-result-list {} 2 | 3 | .search-result-list__item {} 4 | 5 | .search-result-list__item:not(:last-child) { 6 | margin-bottom: 40px; 7 | } 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | !.eleventy.js 4 | src/html 5 | src/css 6 | src/js 7 | src/tools 8 | src/recipes 9 | src/a11y 10 | src/pages 11 | src/people 12 | src/specials 13 | -------------------------------------------------------------------------------- /src/libs/title-formatter/title-formatter.js: -------------------------------------------------------------------------------- 1 | function titleFormatter(segments) { 2 | return segments.filter(Boolean).join(' — ') 3 | } 4 | 5 | module.exports = { 6 | titleFormatter, 7 | } 8 | -------------------------------------------------------------------------------- /src/styles/blocks/code-fix.css: -------------------------------------------------------------------------------- 1 | /* https://github.com/doka-guide/platform/issues/838 */ 2 | .code-fix { 3 | line-height: 0; 4 | overflow-wrap: break-word; 5 | word-break: break-word; 6 | } 7 | -------------------------------------------------------------------------------- /src/styles/blocks/base-list.css: -------------------------------------------------------------------------------- 1 | .base-list { 2 | margin: 0; 3 | padding: 0; 4 | list-style-image: url('data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg"/%3E'); 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jest-environment-node', 3 | setupFilesAfterEnv: ['/jest.setup.js'], 4 | transform: { 5 | '\\.[jt]sx?$': 'babel-jest', 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /src/images/baseline/flag.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/scripts/libs/__tests__/example.js: -------------------------------------------------------------------------------- 1 | const functionToTest = (arg) => `🐿${arg}` 2 | 3 | it('добавляет белочку в начале слова', () => { 4 | expect(functionToTest(' любит орешки')).toBe('🐿 любит орешки') 5 | }) 6 | -------------------------------------------------------------------------------- /src/styles/blocks/snow-25.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable-next-line selector-id-pattern */ 2 | #snowCanvas { 3 | position: fixed; 4 | inset: 0; 5 | z-index: 100; 6 | display: block; 7 | pointer-events: none; 8 | } 9 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | const { TextEncoder, TextDecoder } = require('util') 2 | 3 | global.TextEncoder = TextEncoder 4 | global.TextDecoder = TextDecoder 5 | 6 | const { JSDOM } = require('jsdom') 7 | 8 | global.JSDOM = JSDOM 9 | -------------------------------------------------------------------------------- /src/images/baseline/no.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/top-banner/bat-teeth.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/styles/blocks/person-grid.css: -------------------------------------------------------------------------------- 1 | .person-grid { 2 | display: grid; 3 | grid-row-gap: 1.6em; 4 | grid-column-gap: 5.35em; 5 | grid-template-columns: repeat(auto-fit, minmax(min(100%, 22em), 1fr)); 6 | } 7 | 8 | .person-grid__item {} 9 | -------------------------------------------------------------------------------- /src/styles/blocks/filter-group.css: -------------------------------------------------------------------------------- 1 | .filter-group { 2 | display: grid; 3 | grid-gap: 6px; 4 | justify-items: start; 5 | } 6 | 7 | @media not all and (width >= 768px) { 8 | .filter-group { 9 | grid-gap: 10px; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine3.16 2 | 3 | ENV PATH_TO_CONTENT=./content 4 | 5 | WORKDIR /platform 6 | COPY . . 7 | 8 | RUN npm ci \ 9 | && node make-links.js 10 | 11 | VOLUME /platform/content 12 | EXPOSE 8080 13 | 14 | ENTRYPOINT ["npm", "start"] 15 | -------------------------------------------------------------------------------- /src/styles/blocks/contributors.css: -------------------------------------------------------------------------------- 1 | .contributors { 2 | margin: 0; 3 | } 4 | 5 | .contributors__item:not(:first-child) { 6 | margin-top: 12px; 7 | } 8 | 9 | .contributors__key, 10 | .contributors__value { 11 | margin: 0; 12 | display: inline; 13 | } 14 | -------------------------------------------------------------------------------- /src/scripts/libs/debounce.js: -------------------------------------------------------------------------------- 1 | export default function debounce(callback, delay) { 2 | let timeout 3 | return function (e) { 4 | timeout && clearTimeout(timeout) 5 | timeout = setTimeout(function () { 6 | callback(e) 7 | }, delay) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/views/featured-json.11tydata.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | permalink: '/featured.json', 3 | 4 | eleventyComputed: { 5 | featuredJson: function (data) { 6 | const { featuredArticles } = data 7 | return featuredArticles ?? [] 8 | }, 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /src/includes/blocks/cookie-notification.njk: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /src/styles/blocks/visually-hidden.css: -------------------------------------------------------------------------------- 1 | .visually-hidden { 2 | position: absolute; 3 | margin: -1px; 4 | clip-path: inset(50%); 5 | clip: rect(0 0 0 0); 6 | overflow: hidden; 7 | width: 1px; 8 | height: 1px; 9 | border: 0; 10 | padding: 0; 11 | white-space: nowrap; 12 | } 13 | -------------------------------------------------------------------------------- /src/transforms/link-transform.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {Window} window 3 | */ 4 | module.exports = function (window) { 5 | window.document 6 | .querySelector('.content') 7 | ?.querySelectorAll('a') 8 | ?.forEach((link) => { 9 | link.classList.add('link') 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /src/includes/analytics/google.njk: -------------------------------------------------------------------------------- 1 | 2 | 8 | -------------------------------------------------------------------------------- /src/styles/blocks/format-block.css: -------------------------------------------------------------------------------- 1 | .format-block { 2 | margin-top: 10px; 3 | margin-bottom: 20px; 4 | overflow: auto; 5 | white-space: pre; 6 | font-size: 1em; 7 | line-height: calc(24 / 18); 8 | letter-spacing: var(--letter-spacing); 9 | font-family: var(--font-family, monospace); 10 | } 11 | -------------------------------------------------------------------------------- /src/views/sitemap.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | {%- for page in pages %} 4 | 5 | {{ page.url }} 6 | {{ page.date | fullDateString }} 7 | 8 | {%- endfor %} 9 | 10 | -------------------------------------------------------------------------------- /config/env.js: -------------------------------------------------------------------------------- 1 | const ENVS = { 2 | DEVELOPMENT: 'development', 3 | PRODUCTION: 'production', 4 | } 5 | const env = process.env.NODE_ENV || ENVS.PRODUCTION 6 | const isProdEnv = env === ENVS.PRODUCTION 7 | const isDevEnv = !isProdEnv 8 | 9 | module.exports = Object.freeze({ 10 | isProdEnv, 11 | isDevEnv, 12 | }) 13 | -------------------------------------------------------------------------------- /src/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | Allow: / 3 | Disallow: /search 4 | Disallow: *demos/ 5 | 6 | User-agent: GoogleBot 7 | Allow: / 8 | Disallow: /search 9 | Disallow: *demos/ 10 | 11 | User-agent: Yandex 12 | Allow: / 13 | Disallow: /search 14 | Disallow: *demos/ 15 | Clean-param: q 16 | 17 | Sitemap: https://doka.guide/sitemap.xml 18 | -------------------------------------------------------------------------------- /src/styles/blocks/inline-code.css: -------------------------------------------------------------------------------- 1 | .inline-code { 2 | padding: 0.025em 0.35em; 3 | font-size: var(--font-size-m); 4 | line-height: var(--font-line-height-m); 5 | font-family: var(--font-family); 6 | letter-spacing: var(--letter-spacing); 7 | border-radius: 2em; 8 | background-color: var(--background-code-color, hsl(var(--color-fade))); 9 | } 10 | -------------------------------------------------------------------------------- /src/promos/b-day.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: '🎂 Доке 2 года!' 3 | color: '275.83deg 100% 71.76%' 4 | design: 'b-day' 5 | startDate: '2023-10-12T00:00:00' 6 | endDate: '2023-11-07T00:00:00' 7 | links: 8 | - emoji: '🗞️' 9 | text: 'Разгадать кроссворд!' 10 | url: 'https://birthday.doka.guide/2023/' 11 | --- 12 | 13 | Разгадывайте кроссворд по фронтенду и забирайте подарки. 14 | -------------------------------------------------------------------------------- /src/styles/blocks/related-articles-list.css: -------------------------------------------------------------------------------- 1 | .related-articles-list {} 2 | 3 | .related-articles-list__item { 4 | display: block; 5 | overflow: hidden; 6 | } 7 | 8 | .related-articles-list__item::before { 9 | content: ''; 10 | display: table; 11 | float: left; 12 | padding-top: 100%; 13 | } 14 | 15 | .related-articles-list__article { 16 | min-height: 100%; 17 | } 18 | -------------------------------------------------------------------------------- /src/styles/blocks/container.css: -------------------------------------------------------------------------------- 1 | .container { 2 | --container-gutter: 10px; 3 | margin-left: auto; 4 | margin-right: auto; 5 | box-sizing: border-box; 6 | max-width: 1680px; 7 | padding-left: var(--container-gutter); 8 | padding-right: var(--container-gutter); 9 | } 10 | 11 | @media (width >= 1024px) { 12 | .container { 13 | --container-gutter: 20px; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/includes/blocks/aside.njk: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /src/styles/blocks/color-picker.css: -------------------------------------------------------------------------------- 1 | .token.color::before { 2 | content: ""; 3 | display: inline-block; 4 | width: 10px; 5 | height: 10px; 6 | margin: var(--color-picker-margin); 7 | background-color: var(--color-picker); 8 | line-height: 0; 9 | } 10 | 11 | .color-picker__inline { 12 | --color-picker-margin: 0 2px 0 0; 13 | } 14 | 15 | .color-picker__grouped { 16 | margin-right: 2px; 17 | } 18 | -------------------------------------------------------------------------------- /src/promos/doka-dvizh.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Дока Движ 🐶' 3 | color: '24.98deg 100% 59.02%' 4 | design: 'b-day' 5 | startDate: '2024-10-09T00:00:00' 6 | endDate: '2024-10-13T00:00:00' 7 | links: 8 | - emoji: '👀' 9 | text: 'Смотреть на Ютубe' 10 | url: 'https://www.youtube.com/watch?v=UGGOrsQiOW0' 11 | --- 12 | 13 | 12 октября в 18:00 GMT+3 приглашаем на Дока.Движ — митап о фронтенде, посвященный 3-ему дню рождения Доки. 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "prettier", "plugin:prettier/recommended", "plugin:jest/recommended"], 3 | 4 | "env": { 5 | "browser": true, 6 | "node": true, 7 | "es2020": true 8 | }, 9 | 10 | "parserOptions": { 11 | "sourceType": "module" 12 | }, 13 | 14 | "rules": { 15 | "semi": ["warn", "never"] 16 | }, 17 | 18 | "globals": { 19 | "JSDOM": "readonly" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | 4 | .idea 5 | .vscode 6 | *.code-workspace 7 | *.sublime-workspace 8 | *.sublime-project 9 | 10 | # симлинки на данные репозитория контента 11 | a11y 12 | html 13 | css 14 | js 15 | tools 16 | recipes 17 | people 18 | about 19 | pages 20 | settings 21 | specials 22 | interviews 23 | 24 | .eslintcache 25 | 26 | # кеш для @11ty/eleventy-cache-assets 27 | .cache 28 | .issues.json 29 | 30 | .env 31 | -------------------------------------------------------------------------------- /src/promos/doka-dvizh-2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Дока Движ 2 👀' 3 | color: '358.61deg 75.55% 55.1%' 4 | design: 'b-day' 5 | startDate: '2025-10-08T00:00:00' 6 | endDate: '2025-10-13T00:00:00' 7 | links: 8 | - emoji: '🎁' 9 | text: 'Смотреть на Ютубe' 10 | url: 'https://www.youtube.com/watch?v=ZGfQj17utIk' 11 | --- 12 | 13 | 12 октября в 16:00 GMT+3 приглашаем на Дока Движ 2 — митап о фронтенде, посвященный 4-му дню рождения Доки. 14 | -------------------------------------------------------------------------------- /src/scripts/core/base-component.js: -------------------------------------------------------------------------------- 1 | class BaseComponent extends EventTarget { 2 | emit(eventType, detail) { 3 | this.dispatchEvent( 4 | new CustomEvent(eventType, { 5 | detail, 6 | }) 7 | ) 8 | } 9 | } 10 | 11 | Object.assign(BaseComponent.prototype, { 12 | on: EventTarget.prototype.addEventListener, 13 | off: EventTarget.prototype.removeEventListener, 14 | }) 15 | 16 | export default BaseComponent 17 | -------------------------------------------------------------------------------- /src/styles/blocks/cookie-notification.css: -------------------------------------------------------------------------------- 1 | .cookie-notification { 2 | position: fixed; 3 | z-index: 3; 4 | bottom: 0; 5 | left: 0; 6 | right: 0; 7 | animation: showCookieBanner 0.6s cubic-bezier(0.65, 0.05, 0.36, 1) both; 8 | } 9 | 10 | .cookie-notification[hidden] { 11 | display: none; 12 | } 13 | 14 | @keyframes showCookieBanner { 15 | from { 16 | opacity: 0; 17 | transform: translateY(100%); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Документация к платформе Доки 2 | 3 | Чтобы вносить вклад в развитие платформы Доки, пожалуйста, прочитайте документы ниже. 4 | 5 | ## Содержание 6 | 7 | - [Как запустить Доку локально](how-to-run.md) 8 | - [Как работает Дока](how-its-work.md) 9 | - [Деплой Доки](deploy.md) 10 | - [Советы по работе с зависимостями](deps.md) 11 | - [Лицензии](license.md) 12 | - [⚠️ Если сайт Доки медленно загружается или не работает совсем](load-fix.md) 13 | -------------------------------------------------------------------------------- /src/images/top-banner/bat-eyes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/includes/blocks/nav-list.njk: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/promos/oklich-stream.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Стрим сегодня' 3 | color: '0 100% 50%' 4 | design: 'stream' 5 | startDate: '2023-12-27T00:00:00' 6 | endDate: '2023-12-28T00:00:00' 7 | links: 8 | - emoji: '▶️' 9 | text: 'Смотреть на YouTube' 10 | url: 'https://www.youtube.com/watch?v=T-RxYGvnyfs' 11 | --- 12 | 13 | Дока и Андрей Ситник про новые цветовые пространства в CSS и опенсорс. Прямой эфир 27 декабря в 19:00 GMT+3. 14 | -------------------------------------------------------------------------------- /src/views/people-csv.11tydata.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | permalink: '/people/info.csv', 3 | 4 | eleventyComputed: { 5 | json: function (data) { 6 | const { peopleData, collections } = data 7 | const people = collections.people 8 | 9 | for (const key in peopleData) { 10 | peopleData[key]['url'] = people.filter((person) => person.fileSlug === peopleData[key].id)[0].data.url 11 | } 12 | return peopleData 13 | }, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /src/views/people-info.11tydata.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | permalink: '/people/info.json', 3 | 4 | eleventyComputed: { 5 | json: function (data) { 6 | const { peopleData, collections } = data 7 | const people = collections.people 8 | 9 | for (const key in peopleData) { 10 | peopleData[key]['url'] = people.filter((person) => person.fileSlug === peopleData[key].id)[0].data.url 11 | } 12 | return peopleData 13 | }, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /src/promos/dream-job.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: '…Вы великолепны!' 3 | color: '68 98% 47%' 4 | design: 'dream-job' 5 | startDate: '2023-11-10T00:00:00' 6 | endDate: '2023-11-17T00:00:00' 7 | links: 8 | - emoji: '☕️' 9 | text: 'Акклиматизация на новой работе' 10 | url: 'https://dream-job.doka.guide/dream-job/acclimate/' 11 | --- 12 | 13 | Вы устроились на работу. А что дальше? Как упростить себе первые недели на новом месте, в новом коллективе? Рассказываем в заключительной главе нашего спецпроекта. 14 | -------------------------------------------------------------------------------- /src/styles/blocks/details.css: -------------------------------------------------------------------------------- 1 | .details { 2 | overflow: hidden; 3 | border: 1px solid hsl(var(--color-fade)); 4 | border-radius: 6px; 5 | } 6 | 7 | .details[open] { 8 | overflow: unset; 9 | } 10 | 11 | .details__summary { 12 | padding: var(--offset); 13 | background-color: hsl(var(--color-fade)); 14 | outline-offset: -2px; 15 | outline-width: 2px; 16 | } 17 | 18 | .details__content { 19 | padding: var(--offset); 20 | } 21 | 22 | .details__content > * { 23 | max-width: 100%; 24 | } 25 | -------------------------------------------------------------------------------- /src/scripts/libs/throttle.js: -------------------------------------------------------------------------------- 1 | export default function throttle(callback) { 2 | let requestId = null 3 | let savedArgs 4 | let savedThis 5 | 6 | function frameFunction() { 7 | callback.apply(savedThis, savedArgs) 8 | requestId = null 9 | } 10 | 11 | function replacedFunction() { 12 | savedThis = this 13 | savedArgs = arguments 14 | 15 | if (requestId === null) { 16 | requestId = requestAnimationFrame(frameFunction) 17 | } 18 | } 19 | 20 | return replacedFunction 21 | } 22 | -------------------------------------------------------------------------------- /src/scripts/modules/persons-list.js: -------------------------------------------------------------------------------- 1 | document.querySelectorAll('.persons-list').forEach((list) => { 2 | const items = list.querySelectorAll('.persons-list__item[hidden]') 3 | const button = list.querySelector('.persons-list__button') 4 | const extraPart = list.querySelector('.persons-list__extra') 5 | 6 | button?.addEventListener('click', () => { 7 | items.forEach((item) => { 8 | item.hidden = false 9 | }) 10 | if (extraPart) { 11 | extraPart.hidden = true 12 | } 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/scripts/modules/transform-article-data.js: -------------------------------------------------------------------------------- 1 | module.exports = function transformArticleData(article) { 2 | const section = article.filePathStem.split('/')[1] 3 | 4 | return { 5 | title: article.data.title, 6 | cover: article.data.cover ?? {}, 7 | get imageLink() { 8 | return this.cover?.mobile 9 | }, 10 | description: article.data.description, 11 | link: `/${section}/${article.fileSlug}/`, 12 | linkTitle: article.data.title.replace(/`/g, ''), 13 | section, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/includes/promos/default.njk: -------------------------------------------------------------------------------- 1 | {% macro promo(color, title, content, links) %} 2 |
3 |

{{ title }}

4 |

{{ content | safe }}

5 | 13 |
14 | {% endmacro %} 15 | -------------------------------------------------------------------------------- /src/promos/pwa-stream.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Стрим в записи' 3 | color: '0 100% 50%' 4 | design: 'stream' 5 | startDate: '2024-05-27T00:00:00' 6 | endDate: '2024-05-29T00:00:00' 7 | links: 8 | - emoji: '▶️' 9 | text: 'Смотреть на YouTube' 10 | url: 'https://www.youtube.com/watch?v=tJVZZ4Y9TA0' 11 | --- 12 | 13 | В Доке ничего нет о PWA, пора об этом поговорить. Мы пригласили Максима Сальникова и Глеба Хмыжникова, популяризаторов этой технологии, чтобы задать интересующие вопросы. Прямой эфир 27 мая в 19:00 GMT+3. 14 | -------------------------------------------------------------------------------- /src/includes/blocks/snow-toggle.njk: -------------------------------------------------------------------------------- 1 |
2 | 6 | 10 |
11 | -------------------------------------------------------------------------------- /src/transforms/article-inline-code-transform.js: -------------------------------------------------------------------------------- 1 | // расстановка классов и атрибутов для элементов кода внутри тела статьи 2 | /** 3 | * @param {Window} window 4 | */ 5 | module.exports = function (window) { 6 | const articleContent = window.document.querySelector('.article__content-inner') 7 | 8 | articleContent?.querySelectorAll('p code, ul code, ol code, table code, figcaption code')?.forEach((codeElement) => { 9 | codeElement.classList.add('inline-code', 'code-fix', 'font-theme', 'font-theme--code') 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /src/views/people-csv.njk: -------------------------------------------------------------------------------- 1 | id,name,url,photo,page-link,most-contributed,articles,practices,answers,stat 2 | {% for person in json %} 3 | {{person.id}},{{person.name}},{{person.url}},{{person.photoURL}},{{person.pageLink}},{{person.mostContributedCategory}},{{person.totalArticles}},{{person.totalPractices}},{{person.totalAnswers}}{% for key, item in person.stat %},{{key}}:{{item}}{% endfor %}{% for key, item in person.practices %},{{key}}:{{item}}{% endfor %}{% for key, item in person.answers %},{{key}}:{{item}}{% endfor %}; 4 | {% endfor %} 5 | -------------------------------------------------------------------------------- /src/promos/algos-after.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Так ли стр👻шны алгоритмы?' 3 | color: '0 100% 50%' 4 | design: 'stream' 5 | startDate: '2024-03-16T00:00:00' 6 | endDate: '2024-03-18T00:00:00' 7 | links: 8 | - emoji: '▶️' 9 | text: 'Смотреть запись' 10 | url: 'https://www.youtube.com/watch?v=TdAX9H--Cxs' 11 | --- 12 | 13 | Полина Гуртовая поговорила с Ильёй Шишковым про то, зачем на самом деле на собеседованиях спрашивают про алгоритмы и пригодятся ли вам эти знания в работе. 14 | 15 | -------------------------------------------------------------------------------- /src/includes/promos/stream.njk: -------------------------------------------------------------------------------- 1 | {% macro promo(color, title, content, links) %} 2 |
3 |

{{ title }}

4 |

{{ content | safe }}

5 | 13 |
14 | {% endmacro %} 15 | -------------------------------------------------------------------------------- /src/views/sc-all.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% from "blocks/social-card.njk" import socialCard %} 10 | {{ socialCard('all', 'Все статьи', cover, true) }} 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/views/sc.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% from "blocks/social-card.njk" import socialCard %} 10 | {{ socialCard(category, categoryName, cover) }} 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/scripts/modules/article-nav.js: -------------------------------------------------------------------------------- 1 | function init() { 2 | const nav = document.querySelector('.article-nav') 3 | 4 | if (!nav) { 5 | return 6 | } 7 | 8 | const button = nav.querySelector('.toggle-button') 9 | 10 | button.addEventListener('click', () => { 11 | nav.classList.toggle('article-nav--open') 12 | 13 | let isExpanded = button.getAttribute('aria-expanded') 14 | isExpanded = isExpanded === 'true' ? 'false' : 'true' 15 | button.setAttribute('aria-expanded', isExpanded) 16 | }) 17 | } 18 | 19 | init() 20 | -------------------------------------------------------------------------------- /src/views/sc-index.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% from "blocks/social-card.njk" import socialCard %} 10 | {{ socialCard(category, categoryName, cover, true) }} 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/promos/dream-job-final.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Трудоустройство' 3 | color: '68 98% 47%' 4 | design: 'dream-job' 5 | startDate: '2023-11-17T00:00:10' 6 | endDate: '2023-11-24T00:00:00' 7 | links: 8 | - emoji: '💼' 9 | text: 'Как устроиться на работу?' 10 | url: 'https://dream-job.doka.guide/' 11 | --- 12 | 13 | Всё от начала поиска работы до знакомства с новым коллективом разобрали в спецпроекте «Трудоустройство». Постарались облегчить вам непростой путь и добавить прозрачности. 14 |
15 | Удачи в поисках идеальной работы! (´▽`ʃ♡ƪ) 16 | -------------------------------------------------------------------------------- /src/scripts/modules/article-aside.js: -------------------------------------------------------------------------------- 1 | import headerComponent from './header.js' 2 | 3 | function init() { 4 | const articleAside = document.querySelector('.article__aside') 5 | 6 | if (!(articleAside && headerComponent)) { 7 | return 8 | } 9 | 10 | const activeClass = 'article__aside--offset' 11 | 12 | headerComponent.on('fixed', () => { 13 | articleAside.classList.add(activeClass) 14 | }) 15 | 16 | headerComponent.on('unfixed', () => { 17 | articleAside.classList.remove(activeClass) 18 | }) 19 | } 20 | 21 | init() 22 | -------------------------------------------------------------------------------- /src/promos/algos.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Так ли стр👻шны алгоритмы?' 3 | color: '0 100% 50%' 4 | design: 'stream' 5 | startDate: '2024-03-13T00:00:00' 6 | endDate: '2024-03-16T00:00:00' 7 | links: 8 | - emoji: '🔔' 9 | text: 'Получить уведомление' 10 | url: 'https://www.youtube.com/watch?v=TdAX9H--Cxs' 11 | --- 12 | 13 | 15 марта в 19:00 GMT+3 Полина Гуртовая поговорит с Ильёй Шишковым про то, зачем на самом деле на собеседованиях спрашивают про алгоритмы и пригодятся ли вам эти знания в работе. 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/update-release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release Draft 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 5 1 * *' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - name: Загрузка платформы 16 | uses: actions/checkout@v4 17 | - name: Создание черновика релиза 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | tag: ${{ github.ref_name }} 21 | run: sh .github/scripts/update-release.sh 22 | -------------------------------------------------------------------------------- /src/includes/blocks/top-banner.njk: -------------------------------------------------------------------------------- 1 | {% macro topBanner(isMainPage = false) %} 2 | 6 | {% endmacro %} -------------------------------------------------------------------------------- /src/includes/promos/dream-job.njk: -------------------------------------------------------------------------------- 1 | {% macro promo(color, title, content, links) %} 2 |
3 |

{{ title }}

4 |

{{ content | safe }}

5 | 13 |
14 | {% endmacro %} 15 | -------------------------------------------------------------------------------- /src/styles/blocks/hotkey.css: -------------------------------------------------------------------------------- 1 | .hotkey { 2 | min-width: 14px; 3 | padding: 1px 6px; 4 | text-align: center; 5 | font-family: var(--font-family); 6 | font-size: 20px; 7 | line-height: 1.2; 8 | letter-spacing: var(--letter-spacing); 9 | border: 1px solid var(--color-border); 10 | border-radius: 5px; 11 | opacity: 0.7; 12 | user-select: none; 13 | } 14 | 15 | @media (width <= 1366px) { 16 | .hotkey { 17 | font-size: 18px; 18 | } 19 | } 20 | 21 | @media not all and (width >= 1024px) { 22 | .hotkey { 23 | display: none; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/styles/blocks/figure.css: -------------------------------------------------------------------------------- 1 | .figure { 2 | margin-inline: 0; 3 | display: flex; 4 | flex-direction: column; 5 | align-items: center; 6 | } 7 | 8 | .figure__content { 9 | margin: 0; 10 | } 11 | 12 | .figure__caption { 13 | padding: 1em; 14 | font-size: var(--font-size-s); 15 | line-height: var(--font-line-height-s); 16 | text-align: center; 17 | } 18 | 19 | .figure__caption-code { 20 | font-size: var(--font-size-s); 21 | line-height: var(--font-line-height-s); 22 | font-family: var(--font-family); 23 | letter-spacing: var(--letter-spacing); 24 | } 25 | -------------------------------------------------------------------------------- /src/promos/border-job.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Ищете работу за границей?' 3 | color: '0 100% 50%' 4 | design: 'stream' 5 | startDate: '2024-02-23T00:00:00' 6 | endDate: '2024-02-28T00:00:00' 7 | links: 8 | - emoji: '▶️' 9 | text: 'Смотреть на YouTube' 10 | url: 'https://www.youtube.com/watch?v=rmEIgbWxTX4' 11 | --- 12 | 13 | Стрим 27 февраля в 19:00 GMT+3. Топ ошибок при поиске работы на международном IT-рынке. 14 | 15 | Любовь Рябова, глава экспертов AgileFluent, расскажет о различиях русскоязычного и англоязычного рынка. 16 | 17 | -------------------------------------------------------------------------------- /src/transforms/table-transform.js: -------------------------------------------------------------------------------- 1 | // Оборачивает таблицы в обёртки с прокруткой 2 | /** 3 | * @param {Window} window 4 | */ 5 | module.exports = function (window) { 6 | window.document 7 | .querySelector('.content') 8 | ?.querySelectorAll('table') 9 | ?.forEach((tableElement) => { 10 | const tableWrapper = window.document.createElement('div') 11 | tableWrapper.classList.add('table-wrapper') 12 | tableWrapper.setAttribute('tabindex', 0) 13 | tableElement.replaceWith(tableWrapper) 14 | tableWrapper.appendChild(tableElement) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /src/styles/blocks/persons-list.css: -------------------------------------------------------------------------------- 1 | .persons-list { 2 | display: inline; 3 | } 4 | 5 | .persons-list__items { 6 | display: inline; 7 | } 8 | 9 | .persons-list__item:not([hidden]) { 10 | display: inline-flex; 11 | } 12 | 13 | .persons-list__link {} 14 | 15 | .persons-list__name {} 16 | 17 | .persons-list__button { 18 | margin: 0; 19 | appearance: none; 20 | padding: 0; 21 | border: 0; 22 | display: inline; 23 | font: inherit; 24 | background-color: transparent; 25 | cursor: pointer; 26 | } 27 | 28 | .persons-list__extra:not([hidden]) { 29 | display: inline; 30 | } 31 | -------------------------------------------------------------------------------- /src/transforms/iframe-attr-transform.js: -------------------------------------------------------------------------------- 1 | const attrs = { 2 | loading: 'lazy', 3 | } 4 | 5 | // Добавляет атрибуты к iframe, если их нет 6 | /** 7 | * @param {Window} window 8 | */ 9 | module.exports = function (window) { 10 | const iframes = window.document.querySelector('.content')?.querySelectorAll('iframe') 11 | 12 | iframes?.forEach((iframe) => { 13 | for (const [attributeName, attributeValue] of Object.entries(attrs)) { 14 | if (!iframe.hasAttribute(attributeName)) { 15 | iframe.setAttribute(attributeName, attributeValue) 16 | } 17 | } 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/views/page.11tydata.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | layout: 'base.njk', 3 | 4 | pagination: { 5 | data: 'collections.pages', 6 | size: 1, 7 | alias: 'pageObject', 8 | }, 9 | 10 | permalink: '{{ (pageObject.data.location or pageObject.fileSlug) | slugify }}/index.html', 11 | 12 | eleventyComputed: { 13 | title: function (data) { 14 | const { pageObject } = data 15 | return pageObject.data.title 16 | }, 17 | 18 | description: function (data) { 19 | const { pageObject } = data 20 | return pageObject.data.description 21 | }, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /src/styles/blocks/index-group-list.css: -------------------------------------------------------------------------------- 1 | .index-group-list { 2 | --list-item-gutter: 11px; 3 | } 4 | 5 | .index-group-list__item {} 6 | 7 | .index-group-list__item + .index-group-list__item { 8 | margin-top: var(--list-item-gutter); 9 | } 10 | 11 | .index-group-list__link {} 12 | 13 | .index-group-list__code { 14 | font-size: var(--font-size-m); 15 | line-height: var(--font-line-height-m); 16 | font-family: var(--font-family); 17 | letter-spacing: var(--letter-spacing); 18 | } 19 | 20 | @media (width >= 768px) { 21 | .index-group-list { 22 | --list-item-gutter: 14px; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/views/specials.11tydata.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | layout: 'base.njk', 3 | 4 | pagination: { 5 | data: 'collections.specials', 6 | size: 1, 7 | alias: 'pageObject', 8 | }, 9 | 10 | permalink: '{{ (pageObject.data.location or pageObject.fileSlug) | slugify }}/index.html', 11 | 12 | eleventyComputed: { 13 | title: function (data) { 14 | const { pageObject } = data 15 | return pageObject.data.title 16 | }, 17 | 18 | description: function (data) { 19 | const { pageObject } = data 20 | return pageObject.data.description 21 | }, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /src/promos/burnout.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Айтишники тоже плачут' 3 | color: '0 100% 50%' 4 | design: 'stream' 5 | startDate: '2024-03-19T00:00:00' 6 | endDate: '2024-03-21T00:00:00' 7 | links: 8 | - emoji: '🧯' 9 | text: 'Смотреть стрим' 10 | url: 'https://www.youtube.com/watch?v=yOi-rsHsENg' 11 | --- 12 | 13 | Если у вас пропала мотивация, всё раздражает, и думаете о том, чтобы уйти в лес, знайте, вы не одиноки. 21 марта в 20:00 GMT+3 обсудим на стриме как избежать профессионального выгорания. Будем говорить с Евгением Котом, техдиром с психологическим образованием, о способах предотвращения выгорания. 14 | -------------------------------------------------------------------------------- /src/styles/blocks/search-category.css: -------------------------------------------------------------------------------- 1 | .search-category { 2 | margin: 0; 3 | padding: var(--offset); 4 | border: 0; 5 | display: flex; 6 | flex-wrap: wrap; 7 | justify-content: center; 8 | gap: 6px; 9 | } 10 | 11 | .search-category__legend {} 12 | 13 | .search-category__item {} 14 | 15 | @media (width >= 1024px) { 16 | .search-category { 17 | flex-direction: column; 18 | align-items: flex-start; 19 | } 20 | } 21 | 22 | @media not all and (width >= 768px) { 23 | .search-category { 24 | flex-direction: column; 25 | align-items: flex-start; 26 | gap: 10px; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/includes/analytics/metrika.njk: -------------------------------------------------------------------------------- 1 | 12 | 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | BASE_URL="https://doka.guide" 2 | SECTIONS="html, css, js, a11y, tools, recipes" 3 | PATH_TO_CONTENT="../content" 4 | CONTENT_REP_FOLDERS="html, css, js, a11y, tools, recipes, people, interviews, pages, specials, settings" 5 | DOKA_ORG="https://github.com/doka-guide" 6 | PLATFORM_REP_GITHUB_URL="https://github.com/doka-guide/platform" 7 | CONTENT_REP_GITHUB_URL="https://github.com/doka-guide/content" 8 | CONTENT_REP_GITHUB="https://github.com/doka-guide/content.git" 9 | CONTENT_HOT_BACKLOG="https://github.com/doka-guide/content/milestone/22" 10 | SERVER_PATH="/web/sites/doka.guide/www/" 11 | GITHUB_TOKEN="" 12 | -------------------------------------------------------------------------------- /src/promos/interview-promo.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Открытый техсобес' 3 | color: '0 100% 50%' 4 | design: 'stream' 5 | startDate: '2023-12-17T00:00:00' 6 | endDate: '2023-12-20T00:00:00' 7 | links: 8 | - emoji: '▶️' 9 | text: 'Смотреть стрим' 10 | url: 'https://www.youtube.com/watch?v=r93nWksNJQc' 11 | --- 12 | 13 | 19 декабря в 19:00 GMT+3 проведём открытое техническое собеседование. Хороший способ проверить свои знания и понять, что нужно подтянуть. Собеседовать будет Зар Захаров. Обещаем добрую и бережную обратную связь, в стиле Доки. Приходите слушать и задавать вопросы! 14 | -------------------------------------------------------------------------------- /src/styles/blocks/table-wrapper.css: -------------------------------------------------------------------------------- 1 | .table-wrapper { 2 | overflow: auto; 3 | } 4 | 5 | .table-wrapper > table { 6 | border-collapse: collapse; 7 | } 8 | 9 | .table-wrapper > table th, 10 | .table-wrapper > table td { 11 | text-align: start; 12 | padding: 0.25em 0.5em; 13 | border: 1px solid hsl(var(--color-fade)); 14 | } 15 | 16 | .table-wrapper > table thead th { 17 | position: relative; 18 | z-index: 0; 19 | } 20 | 21 | .table-wrapper > table thead th::before { 22 | content: ''; 23 | opacity: 0.6; 24 | position: absolute; 25 | z-index: -1; 26 | inset: 0; 27 | background-color: hsl(var(--color-fade)); 28 | } 29 | -------------------------------------------------------------------------------- /src/styles/blocks/feedback-control-list.css: -------------------------------------------------------------------------------- 1 | .feedback-control-list { 2 | display: flex; 3 | flex-wrap: wrap; 4 | align-items: baseline; 5 | align-content: flex-start; 6 | justify-content: center; 7 | gap: 6px; 8 | row-gap: 10px; 9 | list-style: none; 10 | } 11 | 12 | .feedback-control-list__item {} 13 | 14 | @media (width >= 768px) { 15 | .feedback-control-list { 16 | gap: 5px; 17 | } 18 | } 19 | 20 | @media (width >= 1024px) { 21 | .feedback-control-list { 22 | gap: 8px; 23 | } 24 | } 25 | 26 | @media (width >= 1680px) { 27 | .feedback-control-list { 28 | gap: 10px; 29 | row-gap: 12px; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/styles/blocks/notification.css: -------------------------------------------------------------------------------- 1 | .notification { 2 | display: flex; 3 | flex-wrap: wrap; 4 | align-items: flex-start; 5 | padding: 10px; 6 | gap: 0.5em; 7 | background-color: var(--color-text); 8 | color: var(--color-background); 9 | } 10 | 11 | .notification__content { 12 | flex: 1 1 12em; 13 | } 14 | 15 | .notification__content, 16 | .notification__button { 17 | padding-block: 0.3em; 18 | } 19 | 20 | .notification__button { 21 | --background: var(--color-background); 22 | flex: 0 0 auto; 23 | padding-inline: 0.85em; 24 | } 25 | 26 | @media (width >= 1366px) { 27 | .notification { 28 | padding: 12px 20px; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/includes/related-articles-gallery.njk: -------------------------------------------------------------------------------- 1 | {% from "blocks/featured-article.njk" import featuredArticle %} 2 | 3 | {% if (relatedArticles and relatedArticles.length > 0) %} 4 | 15 | {% endif %} 16 | 17 | -------------------------------------------------------------------------------- /src/includes/blocks/search-category.njk: -------------------------------------------------------------------------------- 1 |
2 | Фильтровать по: 3 | 4 | 5 | {% for category in collections.articleIndexes %} 6 | 12 | {% endfor %} 13 |
14 | -------------------------------------------------------------------------------- /src/styles/blocks/person-avatar.css: -------------------------------------------------------------------------------- 1 | .person-avatar { 2 | position: relative; 3 | width: var(--avatar-size); 4 | height: var(--avatar-size); 5 | border-radius: 50%; 6 | } 7 | 8 | .person-avatar__item { 9 | position: absolute; 10 | top: 0; 11 | left: 0; 12 | width: 100%; 13 | height: 100%; 14 | border-radius: inherit; 15 | } 16 | 17 | .person-avatar__image { 18 | object-fit: cover; 19 | object-position: 50% 50%; 20 | } 21 | 22 | .person-avatar__placeholder { 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | font-family: var(--font-family); 27 | background-color: var(--accent-color, hsl(var(--color-fade))); 28 | } 29 | -------------------------------------------------------------------------------- /src/includes/blocks/theme-toggle.njk: -------------------------------------------------------------------------------- 1 |
2 | 6 | 10 | 14 |
15 | -------------------------------------------------------------------------------- /src/styles/blocks/base.css: -------------------------------------------------------------------------------- 1 | .base { 2 | --is-header-sticky: 1; 3 | --header-height: 56; 4 | font-size: var(--font-size-m, 100%); 5 | line-height: var(--font-line-height-m, 1.15); 6 | font-family: var(--font-family, sans-serif); 7 | color: var(--color-text); 8 | background-color: var(--color-background); 9 | -webkit-text-size-adjust: 100%; 10 | scroll-behavior: smooth; 11 | } 12 | 13 | /* TODO: рассчитать правильный отступ при наличии класса aria__controls--expanded */ 14 | 15 | .base__body { 16 | display: grid; 17 | grid-template-rows: auto 1fr auto; 18 | min-block-size: 100dvh; 19 | margin: 0; 20 | } 21 | 22 | .base__body:has(.header--open) { 23 | top: 56px; 24 | } 25 | -------------------------------------------------------------------------------- /config/category-colors.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | html: { 3 | light: 'hsl(25 100% 59%)', 4 | dark: 'hsl(25 70% 24%)', 5 | }, 6 | css: { 7 | light: 'hsl(209 100% 59%)', 8 | dark: 'hsl(209 70% 24%)', 9 | }, 10 | js: { 11 | light: 'hsl(49 100% 58%)', 12 | dark: 'hsl(49 74% 23%)', 13 | }, 14 | tools: { 15 | light: 'hsl(122 78% 58%)', 16 | dark: 'hsl(122 56% 23%)', 17 | }, 18 | recipes: { 19 | light: 'hsl(276 100% 72%)', 20 | dark: 'hsl(276 39% 32%)', 21 | }, 22 | a11y: { 23 | light: 'hsl(162 90% 51%)', 24 | dark: 'hsl(162 95% 15%)', 25 | }, 26 | default: { 27 | light: 'hsl(0 0% 100%)', 28 | dark: 'hsl(0 0% 0%)', 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /src/styles/blocks/snow.css: -------------------------------------------------------------------------------- 1 | .snow { 2 | --animation-name: snowfall; 3 | position: fixed; 4 | left: 0; 5 | right: 0; 6 | top: 0; 7 | bottom: 0; 8 | z-index: 100; 9 | display: flex; 10 | justify-content: space-between; 11 | pointer-events: none; 12 | } 13 | 14 | .snow__flake { 15 | position: relative; 16 | top: -1.5em; 17 | color: #c1dcec; 18 | animation-name: var(--animation-name); 19 | animation-timing-function: ease-in-out; 20 | animation-iteration-count: infinite; 21 | will-change: transform; 22 | } 23 | 24 | @keyframes snowfall { 25 | 0% { 26 | transform: translateY(0); 27 | } 28 | 29 | 100% { 30 | transform: translateY(calc(100vh + 1.5em)); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | 4 | "rules": { 5 | "block-no-empty": null, 6 | "custom-property-empty-line-before": null, 7 | "selector-class-pattern": null, 8 | "hue-degree-notation": null, 9 | "property-no-vendor-prefix": null, 10 | "alpha-value-notation": null, 11 | "value-keyword-case": null, 12 | "selector-no-vendor-prefix": null, 13 | "custom-property-no-missing-var-function": null, 14 | "declaration-block-no-redundant-longhand-properties": null, 15 | "keyframes-name-pattern": null, 16 | "font-family-name-quotes": null, 17 | "color-function-notation": null, 18 | "declaration-empty-line-before": null 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/styles/blocks/articles-gallery.css: -------------------------------------------------------------------------------- 1 | .articles-gallery { 2 | padding-bottom: 20px; 3 | padding-bottom: clamp(20px, 7.3%, 120px); 4 | } 5 | 6 | .articles-gallery__title { 7 | margin-top: 0; 8 | margin-bottom: 0.75em; 9 | font-weight: normal; 10 | font-size: var(--font-size-xl); 11 | line-height: var(--font-line-height-xl); 12 | } 13 | 14 | .articles-gallery__more-button { 15 | --button-gutter: 20px; 16 | margin: var(--button-gutter) auto; 17 | display: block; 18 | width: max-content; 19 | } 20 | 21 | .articles-gallery__more-button[hidden] { 22 | display: none; 23 | } 24 | 25 | @media (width >= 768px) { 26 | .articles-gallery__more-button { 27 | --button-gutter: 30px; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/scripts/modules/linked-article-navigation.js: -------------------------------------------------------------------------------- 1 | function init() { 2 | const [previous, next] = [ 3 | document.querySelector('.linked-article--previous .linked-article__link'), 4 | document.querySelector('.linked-article--next .linked-article__link'), 5 | ] 6 | 7 | if (!(previous && next)) { 8 | return 9 | } 10 | 11 | function goToArticle(event) { 12 | if (!(event.ctrlKey && event.altKey)) { 13 | return 14 | } 15 | 16 | const link = { 17 | ArrowLeft: previous, 18 | ArrowRight: next, 19 | }[event.code] 20 | 21 | if (!link) { 22 | return 23 | } 24 | 25 | window.location = link.href 26 | } 27 | 28 | document.addEventListener('keyup', goToArticle) 29 | } 30 | 31 | init() 32 | -------------------------------------------------------------------------------- /src/scripts/modules/toc-text-crop.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Скрипт для обрезки длины заголовка секции в боковой навигации 3 | */ 4 | 5 | const tocLinks = document.querySelectorAll('.toc__link') 6 | export const MAX_LENGTH = 90 7 | 8 | export const clipContent = (linksArray, maxLength) => { 9 | linksArray.forEach((link) => { 10 | const linkText = link.textContent.trim().replace(/\s+/g, ' ') 11 | 12 | if (linkText.length > maxLength) { 13 | const linkTextCropped = linkText.substr(0, maxLength) 14 | const indexOfLastSpace = linkTextCropped.lastIndexOf(' ') 15 | const resultText = linkTextCropped.slice(0, indexOfLastSpace) 16 | 17 | link.textContent = `${resultText}…` 18 | } 19 | }) 20 | } 21 | 22 | clipContent(tocLinks, MAX_LENGTH) 23 | -------------------------------------------------------------------------------- /src/includes/blocks/person-avatar.njk: -------------------------------------------------------------------------------- 1 | {% macro personAvatar(photoURL, photoAlt, name, category, class) %} 2 | {% if (category) %} 3 | {% set inlineStyles %} 4 | --accent-color: var(--color-{{ category }}) 5 | {% endset %} 6 | {% endif %} 7 | 8 |
12 | {% if photoURL %} 13 | {% if photoAlt %}{{ photoAlt }}{% endif %} 14 | {% else %} 15 | 16 | {% endif %} 17 |
18 | {% endmacro %} 19 | -------------------------------------------------------------------------------- /src/scripts/modules/cookie-notification.js: -------------------------------------------------------------------------------- 1 | function init() { 2 | const banner = document.querySelector('.cookie-notification') 3 | const button = banner?.querySelector('button') 4 | 5 | if (!banner && !button) { 6 | return 7 | } 8 | 9 | const storageKey = 'cookie-notification' 10 | 11 | try { 12 | const isCookieAccepted = JSON.parse(localStorage.getItem(storageKey)) 13 | 14 | if (isCookieAccepted) { 15 | return 16 | } 17 | } catch (error) { 18 | console.error(error) 19 | } 20 | 21 | banner.hidden = false 22 | 23 | button.addEventListener( 24 | 'click', 25 | () => { 26 | banner.hidden = true 27 | localStorage.setItem(storageKey, true) 28 | }, 29 | { once: true }, 30 | ) 31 | } 32 | 33 | init() 34 | -------------------------------------------------------------------------------- /src/layouts/base.njk: -------------------------------------------------------------------------------- 1 | {% if category %} 2 | {% set rootStyles %} 3 | --accent-color: var(--color-{{ category }}); 4 | --accent-base-color: var(--color-base-{{ category }}); 5 | {% endset %} 6 | {% endif %} 7 | 8 | 9 | 15 | 16 | 17 | {% include "meta.njk" %} 18 | 19 | 20 | 21 | {{ content | safe }} 22 | 23 | {% include "blocks/cookie-notification.njk" %} 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/transforms/details-transform.js: -------------------------------------------------------------------------------- 1 | // Оборачивает содержимое details в блоки с классом `.content` 2 | /** 3 | * @param {Window} window 4 | */ 5 | module.exports = function (window) { 6 | window.document 7 | .querySelector('.article__content-inner') 8 | ?.querySelectorAll('details') 9 | ?.forEach((detailsElement) => { 10 | const summaryElement = detailsElement.removeChild(detailsElement.firstElementChild) 11 | const detailsContent = detailsElement.innerHTML 12 | 13 | detailsElement.classList.add('details') 14 | summaryElement.classList.add('details__summary') 15 | detailsElement.innerHTML = ` 16 | ${summaryElement.outerHTML} 17 |
${detailsContent}
18 | ` 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/images/partners/practicum-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/scripts/core/search-api-client.js: -------------------------------------------------------------------------------- 1 | class SearchAPIClient { 2 | constructor(url) { 3 | this.url = url 4 | } 5 | 6 | // формирование корректного для системы поискового запроса 7 | search(query, filters = []) { 8 | let url = new URL(this.url) 9 | let params = new URLSearchParams(url.search) 10 | params.append('search', query.replaceAll('+', '%2B').replaceAll('-', '%2D')) 11 | filters.forEach((f) => { 12 | params.append(f.key, f.val) 13 | }) 14 | url.search = params 15 | return fetch(url, { 16 | headers: { 17 | Accept: 'application/json', 18 | }, 19 | }).then((response) => response.json()) 20 | } 21 | } 22 | 23 | const searchClient = new SearchAPIClient('https://search.doka.guide') 24 | 25 | export default searchClient 26 | -------------------------------------------------------------------------------- /src/styles/blocks/breadcrumbs.css: -------------------------------------------------------------------------------- 1 | .breadcrumbs { 2 | display: flex; 3 | align-items: baseline; 4 | font-size: var(--font-size-l); 5 | } 6 | 7 | .header__breadcrumbs { 8 | min-width: 0; 9 | } 10 | 11 | @media not all and (width >= 768px) { 12 | .header--sticky .header__breadcrumbs { 13 | flex-wrap: wrap; 14 | } 15 | } 16 | 17 | .breadcrumbs__item { 18 | display: flex; 19 | align-items: baseline; 20 | } 21 | 22 | .breadcrumbs__text { 23 | --stroke-width: 2px; 24 | --text-font-size: 0.85em; 25 | letter-spacing: -.08em; 26 | line-height: 1; 27 | white-space: nowrap; 28 | text-overflow: ellipsis; 29 | text-underline-offset: 0.2em; 30 | overflow: hidden; 31 | } 32 | 33 | .breadcrumbs .link::after, 34 | .breadcrumbs__item .link::after { 35 | display: none; 36 | } 37 | -------------------------------------------------------------------------------- /src/promos/oklich-questions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Собираем вопросы для стрима' 3 | color: '65 100% 25%' 4 | design: 'stream' 5 | startDate: '2023-12-23T00:00:00' 6 | endDate: '2023-12-25T00:00:00' 7 | links: 8 | - emoji: '🤔' 9 | text: 'Задать вопрос' 10 | url: 'https://t.me/doka_guide/120' 11 | --- 12 | 13 | Проведём предновогодний стрим с Андреем Ситником из Злых Марсиан. Поговорим про OKLCH, Polychrom и опенсорс. 14 | 15 | Решили собрать ваши вопросы к Андрею. Зададим их в прямом эфире. Пишите их в комментариях к посту в нашем тг-канале. 16 | 17 | Дату и время объявим чуть позже. Следите за соцсетями или подпишитесь на наш ютуб. 18 | -------------------------------------------------------------------------------- /src/styles/base-colors.css: -------------------------------------------------------------------------------- 1 | /* базовая палитра цветов */ 2 | :root { 3 | color-scheme: light dark; 4 | 5 | --color-light: 0 0% 100%; 6 | --color-dark: 220 7% 17%; 7 | --color-black: 0 0% 0%; 8 | 9 | --color-red: 360 100% 80%; 10 | --color-orange: 25 100% 59%; 11 | --color-blue: 209 100% 59%; 12 | --color-yellow: 49 100% 58%; 13 | --color-green: 122 78% 58%; 14 | --color-pink: 346 81% 78%; 15 | --color-violet: 276 100% 72%; 16 | --color-aquamarine: 162 90% 51%; 17 | 18 | --color-base-html: hsl(var(--color-orange)); 19 | --color-base-css: hsl(var(--color-blue)); 20 | --color-base-js: hsl(var(--color-yellow)); 21 | --color-base-tools: hsl(var(--color-green)); 22 | --color-base-recipes: hsl(var(--color-violet)); 23 | --color-base-a11y: hsl(var(--color-aquamarine)); 24 | } 25 | -------------------------------------------------------------------------------- /src/promos/interview-search.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Может, ты?' 3 | color: '0 100% 50%' 4 | design: 'stream' 5 | startDate: '2023-12-15T00:00:00' 6 | endDate: '2023-12-17T00:00:00' 7 | links: 8 | - emoji: '👤' 9 | text: 'Заполнить анкету' 10 | url: 'https://docs.google.com/forms/d/1uMPGQ-a7TkdbnlKiQPUsPHdadstxUSm8DtKPxQZwLRI/edit' 11 | - emoji: '▶️' 12 | text: 'Ссылка на стрим' 13 | url: 'https://www.youtube.com/watch?v=r93nWksNJQc' 14 | --- 15 | 16 | 19 декабря проведём открытое техническое собеседование. Хороший способ проверить свои знания и понять, что нужно подтянуть. Собеседовать будет Зар Захаров. Обещаем добрую и бережную обратную связь, в стиле Доки. Нам нужен тот, кого будем собеседовать. Если это ты, то заполни анкету по ссылке ниже. 17 | -------------------------------------------------------------------------------- /src/images/icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/scripts/modules/filter-panel.js: -------------------------------------------------------------------------------- 1 | function init() { 2 | const filterPanel = document.querySelector('.filter-panel') 3 | const button = document.querySelector('.filter-panel__button') 4 | const buttonName = document.querySelector('.float-button__name') 5 | let expanded = false 6 | 7 | if (!filterPanel && !button) { 8 | return 9 | } 10 | 11 | button.addEventListener('click', () => { 12 | filterPanel.classList.toggle('filter-panel--open') 13 | 14 | if (expanded) { 15 | button.setAttribute('aria-expanded', 'false') 16 | buttonName.innerHTML = 'Показать фильтр' 17 | expanded = false 18 | } else { 19 | button.setAttribute('aria-expanded', 'true') 20 | buttonName.innerHTML = 'Скрыть фильтр' 21 | expanded = true 22 | } 23 | }) 24 | } 25 | 26 | init() 27 | -------------------------------------------------------------------------------- /src/views/offline.njk: -------------------------------------------------------------------------------- 1 | {% from "blocks/header.njk" import header with context %} 2 | {% from "blocks/footer.njk" import footer with context %} 3 | 4 | {{ header(isLogoImageHidden=true) }} 5 | 6 |
7 |
8 | 13 |

14 | Дока сейчас оффлайн!,
15 | Эта статья недоступна,,
16 | но смотрите, какие уже есть: 17 |

18 |
19 | 20 |
21 | {% include "articles-gallery.njk" %} 22 |
23 |
24 | 25 | {{ footer() }} 26 | -------------------------------------------------------------------------------- /src/images/baseline/preview.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/views/sc.11tydata.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | pagination: { 3 | data: 'collections.docs', 4 | size: 1, 5 | alias: 'doc', 6 | }, 7 | 8 | permalink: '{{doc.filePathStem}}.sc.html', 9 | 10 | eleventyComputed: { 11 | cover: function (data) { 12 | const { doc } = data 13 | return doc.data.cover 14 | }, 15 | 16 | docPath: function (data) { 17 | const { doc } = data 18 | return doc.filePathStem.replace('index', '') 19 | }, 20 | 21 | category: function (data) { 22 | const { doc } = data 23 | return doc.filePathStem.split('/')[1] 24 | }, 25 | 26 | categoryName: function (data) { 27 | const { category, collections } = data 28 | return collections.articleIndexes.find((section) => section.fileSlug === category)?.data.name 29 | }, 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /src/styles/dark-theme.css: -------------------------------------------------------------------------------- 1 | @import url("code-dark-theme.css"); 2 | 3 | /* настройки для тёмной темы */ 4 | :root { 5 | color-scheme: dark; 6 | 7 | --is-light-theme-on: 0; 8 | --is-dark-theme-on: 1; 9 | 10 | --color-base-text: var(--color-light); 11 | --color-base-background: var(--color-dark); 12 | 13 | --color-text: hsl(var(--color-light)); 14 | --color-background: hsl(var(--color-dark)); 15 | --color-fade: 220 7% 28%; 16 | --color-border: hsl(var(--color-base-text) / 0.3); 17 | 18 | --color-html: hsl(25 70% 24%); 19 | --color-css: hsl(209 70% 24%); 20 | --color-js: hsl(49 74% 23%); 21 | --color-tools: hsl(122 56% 23%); 22 | --color-recipes: hsl(276 39% 32%); 23 | --color-a11y: hsl(162 95% 15%); 24 | 25 | --color-subscribe-success: hsl(124 46% 29%); 26 | --color-subscribe-error: hsl(360 100% 28%); 27 | } 28 | -------------------------------------------------------------------------------- /src/scripts/modules/search-page-filter.js: -------------------------------------------------------------------------------- 1 | function init() { 2 | const filterPanel = document.querySelector('.search-page__aside') 3 | const button = document.querySelector('.search-page__aside-button') 4 | const buttonName = document.querySelector('.search-page__aside-button-name') 5 | let expanded = false 6 | 7 | if (!filterPanel && !button) { 8 | return 9 | } 10 | 11 | button.addEventListener('click', () => { 12 | filterPanel.classList.toggle('search-page__aside--open') 13 | 14 | if (expanded) { 15 | button.setAttribute('aria-expanded', 'false') 16 | buttonName.innerHTML = 'Показать фильтр' 17 | expanded = false 18 | } else { 19 | button.setAttribute('aria-expanded', 'true') 20 | buttonName.innerHTML = 'Скрыть фильтр' 21 | expanded = true 22 | } 23 | }) 24 | } 25 | 26 | init() 27 | -------------------------------------------------------------------------------- /src/views/404.njk: -------------------------------------------------------------------------------- 1 | {% from "blocks/header.njk" import header with context %} 2 | {% from "blocks/footer.njk" import footer with context %} 3 | 4 | {{ header(isLogoImageHidden=true) }} 5 | 6 |
7 |
8 | 15 |

16 | Мы ничего не нашли,
17 | но смотрите, что есть ещё: 18 |

19 |
20 | 21 |
22 | {% include "articles-gallery.njk" %} 23 |
24 |
25 | 26 | {{ footer() }} 27 | -------------------------------------------------------------------------------- /src/styles/blocks/search-tag.css: -------------------------------------------------------------------------------- 1 | .search-tag { 2 | margin: 0; 3 | padding: var(--offset); 4 | border: 0; 5 | display: flex; 6 | justify-content: center; 7 | font-size: var(--font-size-s); 8 | line-height: var(--font-line-height-s); 9 | } 10 | 11 | .search-tag__legend {} 12 | 13 | .search-tag__switch {} 14 | 15 | @media not all and (width >= 1024px) { 16 | .search-tag__legend { 17 | display: none; 18 | } 19 | } 20 | 21 | @media (width >= 1024px) { 22 | .search-tag { 23 | flex-direction: column; 24 | align-items: flex-start; 25 | gap: 6px; 26 | } 27 | } 28 | 29 | @media not all and (width >= 768px) { 30 | .search-tag { 31 | align-items: flex-start; 32 | flex-direction: column; 33 | gap: 10px; 34 | padding-bottom: 20px; 35 | } 36 | 37 | .search-tag__legend { 38 | display: block; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/styles/blocks/copy-button.css: -------------------------------------------------------------------------------- 1 | .copy-button { 2 | position: relative; 3 | margin: 0; 4 | padding: 2px 0; 5 | border: 0; 6 | display: grid; 7 | align-items: center; 8 | grid-auto-flow: column; 9 | grid-gap: 6px; 10 | background-color: transparent; 11 | font-size: var(--font-size-s); 12 | line-height: calc(20 / 15); 13 | cursor: pointer; 14 | } 15 | 16 | .copy-button__icon {} 17 | 18 | .copy-button__text {} 19 | 20 | .copy-button__text::before { 21 | content: ''; 22 | position: absolute; 23 | inset: 0; 24 | } 25 | 26 | .copy-button:disabled, 27 | .copy-button:active { 28 | color: inherit; 29 | } 30 | 31 | .copy-button:not([data-state="idle"]) [data-state="idle"], 32 | .copy-button:not([data-state="success"]) [data-state="success"], 33 | .copy-button:not([data-state="error"]) [data-state="error"] { 34 | display: none; 35 | } 36 | -------------------------------------------------------------------------------- /src/transforms/color-picker-transform.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {Window} window 3 | */ 4 | module.exports = function (window) { 5 | const content = window.document.querySelector('.article__content-inner') 6 | const color = content?.querySelectorAll('.token.color') 7 | 8 | color?.forEach(function (item) { 9 | if (/(transparent)/.test(item.textContent)) { 10 | return item.classList.replace('color', 'color-transparent') // Выключает color-picker для цвета transparent 11 | } 12 | 13 | item.style.setProperty('--color-picker', ` ${item.textContent}`) 14 | item.classList.add('color-picker__inline') 15 | 16 | if (/[(]/.test(item.previousElementSibling.textContent)) { 17 | item.previousElementSibling.classList.add('color-picker__grouped') // Добавляет дополнительный margin скобке, если затем следует токен цвета 18 | } 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: Автоматизация 4 | labels: 5 | - автоматизация 6 | - title: Вопрос 7 | labels: 8 | - вопрос 9 | - title: Вёрстка 10 | labels: 11 | - вёрстка 12 | - title: Движок 13 | labels: 14 | - движок 15 | - title: Дизайн 16 | labels: 17 | - дизайн 18 | - title: Доступность 19 | labels: 20 | - доступность 21 | - title: Кухня 22 | labels: 23 | - кухня 24 | - title: Ошибка 25 | labels: 26 | - ошибка 27 | - title: Поиск 28 | labels: 29 | - поиск 30 | - title: Улучшение 31 | labels: 32 | - улучшение 33 | - title: Хороший старт 34 | labels: 35 | - хороший старт 36 | - title: Другие изменения 37 | labels: 38 | - "*" 39 | -------------------------------------------------------------------------------- /src/styles/light-theme.css: -------------------------------------------------------------------------------- 1 | /* настройки светлой темы (тема по умолчанию) */ 2 | :root { 3 | color-scheme: light; 4 | 5 | --is-light-theme-on: 1; 6 | --is-dark-theme-on: 0; 7 | 8 | --color-base-text: var(--color-dark); 9 | --color-base-background: var(--color-light); 10 | 11 | --color-text: hsl(var(--color-dark)); 12 | --color-background: hsl(var(--color-light)); 13 | --color-fade: 0 0% 82%; 14 | --color-border: hsl(var(--color-base-text) / 0.3); 15 | 16 | --color-html: hsl(var(--color-orange)); 17 | --color-css: hsl(var(--color-blue)); 18 | --color-js: hsl(var(--color-yellow)); 19 | --color-tools: hsl(var(--color-green)); 20 | --color-recipes: hsl(var(--color-violet)); 21 | --color-a11y: hsl(var(--color-aquamarine)); 22 | 23 | --color-subscribe-success: hsl(var(--color-green)); 24 | --color-subscribe-error: hsl(var(--color-red)); 25 | } 26 | -------------------------------------------------------------------------------- /src/views/feed.11tydata.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | permalink: 'feed/index.xml', 3 | eleventyExcludeFromCollections: true, 4 | meta: { 5 | title: 'Новое в Доке', 6 | subtitle: 'Дока — это документация для разработчиков на понятном языке.', 7 | language: 'ru', 8 | url: 'https://doka.guide/', 9 | author: { 10 | name: 'Дока Дог', 11 | email: 'hi@doka.guide', 12 | }, 13 | }, 14 | 15 | eleventyComputed: { 16 | posts: async function (data) { 17 | const { collections } = data 18 | return collections.posts.filter((p) => typeof p === 'object').sort((p1, p2) => p1.date - p2.date) 19 | }, 20 | updated: async function (data) { 21 | const { posts } = data 22 | if (posts[0]) { 23 | return posts[0]?.date 24 | } else { 25 | return new Date().toISOString() 26 | } 27 | }, 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /src/includes/blocks/search-tags.njk: -------------------------------------------------------------------------------- 1 |
2 | Показывать: 3 | 4 | 5 |
6 | 10 | 14 | 18 |
19 |
20 | -------------------------------------------------------------------------------- /src/libs/role-constructor/collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "doka-core-team": { 3 | "title": "Редакция", 4 | "url": "https://doka.guide/about/#kto-my", 5 | "color": "hsl(0 0% 0%)", 6 | "bgColor": "hsl(0 0% 100%)", 7 | "hoverColor": "hsl(0 0% 94%)", 8 | "border": "hsl(0 0% 0%)" 9 | }, 10 | "praktikum-mentor": { 11 | "title": "Наставник Практикума", 12 | "url": "https://practicum.yandex.ru/catalog/programming/", 13 | "color": "hsl(0 0% 100%)", 14 | "bgColor": "hsl(0 0% 0%)", 15 | "hoverColor": "hsl(0 0% 30%)", 16 | "border": "hsl(0 0% 0%)" 17 | }, 18 | "practicum-contributor": { 19 | "title": "Контрибьютор Практикума", 20 | "url": "https://practicum.yandex.ru/catalog/programming/", 21 | "color": "hsl(0 0% 100%)", 22 | "bgColor": "hsl(0 0% 0%)", 23 | "hoverColor": "hsl(0 0% 30%)", 24 | "border": "hsl(0 0% 0%)" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/styles/fonts.sc.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Graphik'; 3 | src: url('../fonts/graphik/graphik-regular.woff2') format('woff2'); 4 | font-style: normal; 5 | font-weight: normal; 6 | font-display: fallback; 7 | } 8 | 9 | @font-face { 10 | font-family: 'Graphik'; 11 | src: url('../fonts/graphik/graphik-regular-italic.woff2') format('woff2'); 12 | font-style: italic; 13 | font-weight: normal; 14 | font-display: fallback; 15 | } 16 | 17 | @font-face { 18 | font-family: 'Graphik'; 19 | src: url('../fonts/graphik/graphik-medium.woff2') format('woff2'); 20 | font-style: normal; 21 | font-weight: 500; 22 | font-display: fallback; 23 | } 24 | 25 | @font-face { 26 | font-family: 'Spot Mono'; 27 | src: url('../fonts/spot-mono/spot-mono-light.woff2') format('woff2'); 28 | font-style: normal; 29 | font-weight: normal; 30 | font-display: fallback; 31 | } 32 | -------------------------------------------------------------------------------- /src/views/specials.njk: -------------------------------------------------------------------------------- 1 | {% from "blocks/header.njk" import header with context %} 2 | {% from "blocks/footer.njk" import footer with context %} 3 | 4 | {% set isLogoContrastColor = hasCategory %} 5 | 6 | {{ header( 7 | title=title, 8 | isLogoContrastColor=isLogoContrastColor 9 | ) }} 10 | 11 |
12 |
13 |
14 |

15 | {{ title }} 16 |

17 | {% if description %} 18 |

19 | {{ description }} 20 |

21 | {% endif %} 22 |
23 |
24 |
25 | {{ pageObject.templateContent | safe }} 26 |
27 |
28 | 29 | {{ footer() }} 30 | -------------------------------------------------------------------------------- /src/views/people-index.11tydata.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | permalink: '/people/index.json', 3 | 4 | eleventyComputed: { 5 | json: function (data) { 6 | const { peopleData, collections } = data 7 | const people = collections.people 8 | 9 | for (const key in peopleData) { 10 | peopleData[key]['url'] = people.filter((person) => person.fileSlug === peopleData[key].id)[0].data.url 11 | } 12 | 13 | const buffer = {} 14 | buffer['images'] = [] 15 | for (const key in peopleData) { 16 | buffer['images'].push(peopleData[key]['photoURL']) 17 | } 18 | buffer['images'] = buffer['images'].filter((img) => img !== null) 19 | 20 | buffer['links'] = [] 21 | for (const key in peopleData) { 22 | buffer['links'].push(peopleData[key]['pageLink']) 23 | } 24 | 25 | return buffer 26 | }, 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /src/styles/blocks/people-page.css: -------------------------------------------------------------------------------- 1 | .people-page { 2 | --offset: 10px; 3 | min-height: 100vh; 4 | } 5 | 6 | .people-page__main { 7 | padding-block: clamp(var(--offset), 8%, 130px); 8 | } 9 | 10 | .people-page__filter { 11 | z-index: 2; 12 | } 13 | 14 | .people-page__list { 15 | flex: 1 1 0%; 16 | padding: var(--offset); 17 | } 18 | 19 | .people-page__footer { 20 | position: sticky; 21 | top: 100vh; 22 | } 23 | 24 | @media (width >= 768px) { 25 | .people-page { 26 | --offset: 20px; 27 | } 28 | 29 | .people-page__main { 30 | position: relative; 31 | z-index: 0; 32 | display: flex; 33 | align-items: flex-start; 34 | } 35 | 36 | .people-page__filter { 37 | position: sticky; 38 | top: calc(var(--header-height) * 1px); 39 | flex: 0 0 14em; 40 | box-sizing: border-box; 41 | transition: top 0.6s; 42 | will-change: top; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/transforms/demo-link-transform.js: -------------------------------------------------------------------------------- 1 | // Правит пути к демкам и картинкам, которые вставлены в раздел «На практике». 2 | // Чтобы сослаться на демку из раздела «На практике» используется относительный путь '../demos/index.html'. 3 | // При сборке сайта, раздел вклеивается в основную статью и относительная ссылка ломается. 4 | // Эта трансформация заменяет '../demos/index.html' на './demos/index.html' 5 | /** 6 | * @param {Window} window 7 | */ 8 | module.exports = function (window) { 9 | const practicesSection = window.document.getElementById('practices') 10 | if (practicesSection) { 11 | const mediaElements = practicesSection.querySelectorAll('img, iframe') 12 | for (const element of mediaElements) { 13 | const oldLink = element.getAttribute('src') 14 | const newLink = oldLink.replace('../', './') 15 | element.setAttribute('src', newLink) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/scripts/modules/code-line-numbers.js: -------------------------------------------------------------------------------- 1 | import debounce from '../libs/debounce.js' 2 | 3 | function init() { 4 | const codeBlocks = document.querySelectorAll('pre[data-lang]') 5 | 6 | if (codeBlocks.length === 0) { 7 | return 8 | } 9 | 10 | function computedHeights() { 11 | codeBlocks.forEach((block) => { 12 | const originalLines = block.querySelectorAll('.block-code__original-line') 13 | const linesMarkers = block.querySelectorAll('.block-code__line') 14 | 15 | originalLines.forEach((line, index) => { 16 | linesMarkers[index].style.height = `${getComputedStyle(line).height}` 17 | }) 18 | }) 19 | } 20 | 21 | const debouncedCallback = debounce(computedHeights, 100) 22 | 23 | window.addEventListener('resize', debouncedCallback) 24 | window.addEventListener('orientationchange', debouncedCallback) 25 | 26 | computedHeights() 27 | } 28 | 29 | init() 30 | -------------------------------------------------------------------------------- /src/styles/blocks/article-image.css: -------------------------------------------------------------------------------- 1 | .article-image { 2 | --aspect-ratio: 16 / 7; 3 | display: grid; 4 | grid-template-columns: 1fr auto; 5 | grid-column-gap: 1em; 6 | } 7 | 8 | .article-image__picture { 9 | position: relative; 10 | display: block; 11 | } 12 | 13 | .article-image__picture::before { 14 | content: ""; 15 | display: block; 16 | padding-top: calc(100% / (var(--aspect-ratio))); 17 | } 18 | 19 | .article-image__content { 20 | position: absolute; 21 | top: 0; 22 | left: 0; 23 | width: 100%; 24 | height: 100%; 25 | object-fit: contain; 26 | object-position: 50% 50%; 27 | } 28 | 29 | .article-image__author { 30 | writing-mode: vertical-rl; 31 | transform: rotate(180deg); 32 | text-align: end; 33 | font-size: 0.64em; 34 | line-height: 1.25; 35 | } 36 | 37 | @media not all and (width >= 425px) { 38 | .article-image { 39 | --aspect-ratio: 1 / 1; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/views/page.njk: -------------------------------------------------------------------------------- 1 | {% from "blocks/header.njk" import header with context %} 2 | {% from "blocks/footer.njk" import footer with context %} 3 | 4 | {% set isLogoContrastColor = hasCategory %} 5 | 6 | {{ header( 7 | title=title, 8 | isLogoContrastColor=isLogoContrastColor 9 | ) }} 10 | 11 |
12 |
13 |
14 |

15 | {{ title }} 16 |

17 | {% if description %} 18 |

19 | {{ description }} 20 |

21 | {% endif %} 22 |
23 |
24 |
25 | {{ pageObject.templateContent | safe }} 26 |
27 |
28 | 29 | {{ footer() }} 30 | {% include "subscribe-popup.njk" %} 31 | -------------------------------------------------------------------------------- /src/libs/role-constructor/role-constructor.js: -------------------------------------------------------------------------------- 1 | const collection = require('./collection.json') 2 | 3 | function getRole(assignedRole) { 4 | let role = {} 5 | if (typeof assignedRole === 'object') { 6 | const keys = Object.keys(assignedRole) 7 | const type = keys.length === 1 ? keys[0] : undefined 8 | if (type && collection[type]) { 9 | const typicalRole = collection[type] 10 | const roleFields = new Set(Object.keys(typicalRole)) 11 | roleFields.add(...Object.keys(assignedRole[type])) 12 | roleFields.forEach((field) => { 13 | if (assignedRole[type][field]) { 14 | role[field] = assignedRole[type][field] 15 | } else { 16 | role[field] = typicalRole[field] 17 | } 18 | }) 19 | } 20 | } else if (typeof assignedRole === 'string') { 21 | role = collection[assignedRole] 22 | } 23 | return role 24 | } 25 | 26 | module.exports = { 27 | getRole, 28 | } 29 | -------------------------------------------------------------------------------- /src/views/feed.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ meta.title }} 4 | {{ meta.subtitle }} 5 | 6 | 7 | {{ updated }} 8 | {{ meta.url }}/ 9 | 10 | {{ meta.author.name }} 11 | {{ meta.author.email }} 12 | 13 | {%- for post in posts %} 14 | 15 | {{ post.title }} 16 | 17 | {{ post.date }} 18 | {{ post.url }} 19 | {% if post.description %} 20 | {{ post.description | htmlToAbsoluteUrls(post.url) }} 21 | {% endif %} 22 | 23 | {%- endfor %} 24 | 25 | -------------------------------------------------------------------------------- /.github/workflows/docker-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Docker Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Настройка QEMU 13 | uses: docker/setup-qemu-action@v3 14 | - name: Настройка Docker Buildx 15 | uses: docker/setup-buildx-action@v3 16 | - name: Авторизация на Docker Hub 17 | uses: docker/login-action@v3 18 | with: 19 | username: ${{ secrets.DOCKERHUB_USERNAME }} 20 | password: ${{ secrets.DOCKERHUB_TOKEN }} 21 | - name: Сборка и публикация 22 | id: docker_build 23 | uses: docker/build-push-action@v5 24 | with: 25 | push: true 26 | tags: dokaguide/platform:latest 27 | platforms: linux/amd64,linux/arm64,linux/x86_64 28 | - name: Обзор загруженного образа 29 | run: echo ${{ steps.docker_build.outputs.digest }} 30 | -------------------------------------------------------------------------------- /src/includes/articles-gallery.njk: -------------------------------------------------------------------------------- 1 | {% from "blocks/featured-article.njk" import featuredArticle %} 2 | 3 | {% set lazyLoadingIndexThreshold = 3 %} 4 | 5 | {% if (featuredArticles and featuredArticles.length > 0) %} 6 | 24 | {% endif %} 25 | 26 | -------------------------------------------------------------------------------- /src/styles/blocks/vote.css: -------------------------------------------------------------------------------- 1 | .vote { 2 | margin: 0; 3 | position: relative; 4 | z-index: 0; 5 | box-sizing: border-box; 6 | width: 109px; 7 | height: 44px; 8 | padding: 0; 9 | border: 3px solid var(--vote-color); 10 | font: inherit; 11 | color: var(--vote-color); 12 | background: 0; 13 | border-radius: 6px; 14 | transition: 125ms background-color; 15 | cursor: pointer; 16 | } 17 | 18 | .vote::before { 19 | content: ''; 20 | opacity: 0; 21 | position: absolute; 22 | inset: 0; 23 | background-color: var(--vote-color); 24 | transition: opacity 0.2s; 25 | } 26 | 27 | .vote--up { 28 | --vote-color: hsl(122, 78%, 58%); 29 | } 30 | 31 | .vote--down { 32 | --vote-color: hsl(346, 81%, 78%); 33 | } 34 | 35 | .vote:disabled { 36 | opacity: 0.5; 37 | pointer-events: none; 38 | } 39 | 40 | .vote:not(:disabled):hover::before { 41 | opacity: 0.1; 42 | } 43 | 44 | .vote--active { 45 | background-color: var(--vote-color); 46 | color: hsl(var(--color-black)); 47 | } 48 | -------------------------------------------------------------------------------- /docs/deploy.md: -------------------------------------------------------------------------------- 1 | # Деплой Доки 2 | 3 | Дока хостится на сервере как набор статических файлов. 4 | 5 | ## Когда деплоим 6 | 7 | Деплоим автоматически, при слиянии пул-реквеста в ветку `main` в репозитории `content` или `platform`. 8 | 9 | ## Как собираем 10 | 11 | Проект собирается с помощью [GitHub Actions](https://docs.github.com/en/actions). 12 | 13 | В каждом репозитории описан свой воркфлоу для сборки, но они идентичны по содержанию: 14 | 15 | - [Воркфлоу контента](https://github.com/doka-guide/content/blob/main/.github/workflows/product-deploy.yml) 16 | - [Воркфлоу платформы](https://github.com/doka-guide/platform/blob/main/.github/workflows/product-deploy.yml) 17 | 18 | Сборка состоит из пяти этапов: 19 | 20 | 1. скачать свежие версии контента и платформы; 21 | 1. установить зависимости; 22 | 1. подключить статьи к платформе; 23 | 1. собрать проект; 24 | 1. отправить папку со сборкой на сервер. 25 | 26 | На стороне сервера не происходит никаких действий, кроме публикации переданной папки со сборкой. 27 | -------------------------------------------------------------------------------- /src/images/baseline/chrome.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/libs/__tests__/collection-helpers.js: -------------------------------------------------------------------------------- 1 | const { setPath } = require('../collection-helpers/set-path') 2 | 3 | describe('setPath', () => { 4 | it('creates object hierarchy', () => { 5 | const foo = {} 6 | const result = setPath(['a', 'b', 'c'], 1, foo) 7 | 8 | expect(result).toEqual({ a: { b: { c: 1 } } }) 9 | }) 10 | 11 | it('sets object value', () => { 12 | const foo = { a: { b: { c: 1 } } } 13 | const result = setPath(['a', 'b', 'c'], 2, foo) 14 | 15 | expect(result).toEqual({ a: { b: { c: 2 } } }) 16 | }) 17 | 18 | it('if path has a number creates an array', () => { 19 | const foo = {} 20 | const result = setPath(['a', 1, 'c'], 2, foo) 21 | 22 | expect(result).toEqual({ a: [undefined, { c: 2 }] }) 23 | }) 24 | 25 | it('if the first element is a number and an initial value is a number creates an array', () => { 26 | const foo = [] 27 | const result = setPath([1, 'c'], 2, foo) 28 | 29 | expect(result).toEqual([undefined, { c: 2 }]) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/styles/blocks/toc.css: -------------------------------------------------------------------------------- 1 | .toc__list .toc__list { 2 | margin-left: 13px; 3 | } 4 | 5 | .toc__item { 6 | padding-inline-start: 10px; 7 | } 8 | 9 | @media (width < 1024px) { 10 | .toc__item:first-of-type { 11 | padding-inline-end: 60px; 12 | } 13 | } 14 | 15 | .toc__link { 16 | margin-left: -10px; 17 | padding: 0 10px 2px; 18 | border-radius: 2em; 19 | } 20 | 21 | .toc__link[href="#na-sobesedovanii"]::after { 22 | content: ' '; 23 | display: inline-block; 24 | width: 17px; 25 | height: 17px; 26 | margin-left: 4px; 27 | background-image: url('../../images/partners/practicum-icon.svg'); 28 | background-size: 13px; 29 | background-repeat: no-repeat; 30 | background-position: 50% 40%; 31 | vertical-align: middle; 32 | } 33 | 34 | .toc__link--active { 35 | background-color: transparent; 36 | } 37 | 38 | @media (width >= 1024px) { 39 | .toc__link--active { 40 | text-decoration: none; 41 | background-color: var(--active-link-background, hsl(var(--color-fade))); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/transforms/headings-id-transform.js: -------------------------------------------------------------------------------- 1 | const { slugify } = require('transliteration') 2 | 3 | // генерация id для заголовков 4 | /** 5 | * @param {Window} window 6 | */ 7 | module.exports = function (window) { 8 | const content = window.document.querySelector('.content') 9 | 10 | if (content) { 11 | let headings = content.querySelectorAll( 12 | 'h2, h3, h4, h5, h6, #questions > div.questions__list > div.question__request > aside > div > p:first-of-type' 13 | ) 14 | 15 | const headingHashMap = {} 16 | 17 | for (const heading of headings) { 18 | const headingText = heading.textContent.trim() 19 | const id = slugify(headingText) 20 | 21 | if (headingHashMap[id] >= 0) { 22 | headingHashMap[id] += 1 23 | } else { 24 | headingHashMap[id] = 0 25 | } 26 | const headingIdPostfix = headingHashMap[id] > 0 ? `-${headingHashMap[id]}` : '' 27 | 28 | heading.setAttribute('id', slugify(headingText) + headingIdPostfix) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/includes/blocks/featured-article.njk: -------------------------------------------------------------------------------- 1 | {% macro featuredArticle(article, class, isLazyLoading=false) %} 2 |
3 | {% if article.imageLink %} 4 | 5 | 11 | 12 | {% endif %} 13 |

14 | 15 | {{ article.title | descriptionMarkdown | safe }} 16 | 17 |

18 | {% if ((not hasImage) and article.description) %} 19 |

20 | {{ article.description | descriptionMarkdown | safe }} 21 |

22 | {% endif %} 23 |
24 | {% endmacro %} 25 | -------------------------------------------------------------------------------- /src/transforms/toc-transform.js: -------------------------------------------------------------------------------- 1 | const HeadingHierarchy = require('../libs/heading-hierarchy/heading-hierarchy') 2 | 3 | // генерация оглавления 4 | /** 5 | * @param {Window} window 6 | */ 7 | module.exports = function (window) { 8 | const articleContent = window.document.querySelector('.article__content-inner') 9 | 10 | if (!articleContent) { 11 | return 12 | } 13 | 14 | const articleNavContent = window.document.querySelector('.article-nav__content') 15 | const headings = Array.from( 16 | articleContent.querySelectorAll( 17 | 'h2, h3, h4, h5, h6, #questions > div.questions__list > div.question__request > aside > div > p:first-of-type' 18 | ) 19 | ) 20 | // не учитываем заголовки, которые лежат внутри тегов details, внутри советов и внутри ответов 21 | .filter((title) => !title.closest('details, .practices__content, .question__response')) 22 | 23 | const hierarchy = HeadingHierarchy.createHierarchy(headings) 24 | articleNavContent.innerHTML = HeadingHierarchy.render(hierarchy) 25 | } 26 | -------------------------------------------------------------------------------- /src/styles/blocks/standalone-page.css: -------------------------------------------------------------------------------- 1 | .standalone-page { 2 | --offset: 10px; 3 | padding-bottom: clamp(20px, 14.7%, 160px); 4 | } 5 | 6 | .standalone-page__wrapper { 7 | margin-left: auto; 8 | margin-right: auto; 9 | max-width: 1087px; 10 | } 11 | 12 | .standalone-page__header { 13 | padding: var(--offset); 14 | padding-top: clamp(20px, 8%, 130px); 15 | border-bottom: 1px solid var(--color-border); 16 | } 17 | 18 | .standalone-page__title { 19 | margin-top: 0; 20 | margin-bottom: 0; 21 | font-size: var(--font-size-xl); 22 | line-height: var(--font-line-height-xl); 23 | font-weight: normal; 24 | } 25 | 26 | .standalone-page__description { 27 | margin-top: 10px; 28 | margin-bottom: 0; 29 | } 30 | 31 | .standalone-page__content { 32 | padding: var(--offset); 33 | } 34 | 35 | @media (width >= 1024px) { 36 | .standalone-page { 37 | --offset: 20px; 38 | } 39 | 40 | .standalone-page__title { 41 | font-size: var(--font-size-xxl); 42 | line-height: var(--font-line-height-xxl); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/images/assets/cached-link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/styles/fonts.css: -------------------------------------------------------------------------------- 1 | /* 2 | Авторские права на шрифт Graphik во всех начертаниях принадлежат ООО «Тайп Тудэй». 3 | Шрифт предоставлен Доке по лицензии: https://type.today/ru/license/today/web 4 | */ 5 | 6 | @font-face { 7 | font-family: 'Graphik'; 8 | src: url('/fonts/graphik/graphik-regular.woff2') format('woff2'); 9 | font-style: normal; 10 | font-weight: normal; 11 | font-display: fallback; 12 | } 13 | 14 | @font-face { 15 | font-family: 'Graphik'; 16 | src: url('/fonts/graphik/graphik-regular-italic.woff2') format('woff2'); 17 | font-style: italic; 18 | font-weight: normal; 19 | font-display: fallback; 20 | } 21 | 22 | @font-face { 23 | font-family: 'Graphik'; 24 | src: url('/fonts/graphik/graphik-medium.woff2') format('woff2'); 25 | font-style: normal; 26 | font-weight: bold; 27 | font-display: fallback; 28 | } 29 | 30 | @font-face { 31 | font-family: 'Spot Mono'; 32 | src: url('/fonts/spot-mono/spot-mono-light.woff2') format('woff2'); 33 | font-style: normal; 34 | font-weight: normal; 35 | font-display: fallback; 36 | } 37 | -------------------------------------------------------------------------------- /src/includes/blocks/linked-article.njk: -------------------------------------------------------------------------------- 1 | {% macro linkedArticle(article, type = 'previous') %} 2 | {% set icon = '←' if type === 'previous' else '→' %} 3 | 4 |
8 | 11 |
12 | 13 | {{ article.title | descriptionMarkdown | safe }} 14 | 15 |
16 | ctrl + alt + {{ icon }} 17 |
18 |
19 |
20 | {% endmacro %} 21 | -------------------------------------------------------------------------------- /src/styles/blocks/suggestion-list.css: -------------------------------------------------------------------------------- 1 | .suggestion-list { 2 | display: inline-grid; 3 | vertical-align: top; 4 | font-size: calc(var(--font-size-m) * 1.25); 5 | line-height: 1.35; 6 | } 7 | 8 | .suggestion-list__item {} 9 | 10 | .suggestion-list__code { 11 | font-size: calc(var(--font-size-m) * 1.17); 12 | line-height: 1; 13 | letter-spacing: var(--letter-spacing); 14 | font-family: var(--font-family); 15 | } 16 | 17 | .suggestion-list__link { 18 | --stroke-width: 2px; 19 | --stroke-color: var(--accent-color); 20 | position: relative; 21 | z-index: 0; 22 | display: block; 23 | padding: 5px; 24 | text-underline-position: auto; 25 | text-underline-offset: 0.2em; 26 | color: inherit; 27 | } 28 | 29 | .suggestion-list__link::before { 30 | content: ''; 31 | opacity: 0; 32 | position: absolute; 33 | z-index: -1; 34 | inset: 0; 35 | border-radius: 6px; 36 | background-color: var(--accent-color); 37 | transition: opacity 125ms; 38 | } 39 | 40 | .suggestion-list__link--highlighted::before, 41 | .suggestion-list__link:hover::before { 42 | opacity: 1; 43 | } 44 | -------------------------------------------------------------------------------- /src/transforms/answers-link-transform.js: -------------------------------------------------------------------------------- 1 | // Правит пути к демкам и картинкам, которые вставлены в раздел «На практике». 2 | // Чтобы сослаться на демку из раздела «На практике» используется относительный путь '../demos/index.html'. 3 | // При сборке сайта, раздел вклеивается в основную статью и относительная ссылка ломается. 4 | // Эта трансформация заменяет '../demos/index.html' на './demos/index.html' 5 | /** 6 | * @param {Window} window 7 | */ 8 | module.exports = function (window) { 9 | const questionsSection = window.document.getElementById('questions') 10 | if (questionsSection) { 11 | const answers = questionsSection.querySelectorAll('.question__answer') 12 | for (const answer of answers) { 13 | const path = `/interviews/${answer.id.split('-answers-').join('/answers/')}` 14 | const mediaElements = answer.querySelectorAll('img, iframe') 15 | for (const element of mediaElements) { 16 | const oldLink = element.getAttribute('src') 17 | const newLink = `${path}/${oldLink}` 18 | element.setAttribute('src', newLink) 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/promos/stream.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Розыгрыш билетов' 3 | color: '68 100% 46%' 4 | design: 'stream' 5 | startDate: '2023-07-13T00:00:00' 6 | endDate: '2023-07-14T00:00:00' 7 | links: 8 | - emoji: '' 9 | text: 'Смотреть в записи' 10 | url: 'https://www.youtube.com/watch?v=1jMr49xg9ac&ab_channel=%D0%94%D0%BE%D0%BA%D0%B0' 11 | --- 12 | 13 | 🎫 Разыгрываем билет на FrontendConf в комментариях к трансляции на YouTube. Итоги уже в понедельник . 14 | -------------------------------------------------------------------------------- /src/scripts/index.js: -------------------------------------------------------------------------------- 1 | import './modules/last-update.js' 2 | import './modules/articles-gallery.js' 3 | import './modules/persons-list.js' 4 | import './modules/article-nav.js' 5 | import './modules/toc.js' 6 | import './modules/toc-text-crop.js' 7 | import './modules/header.js' 8 | import './modules/article-aside.js' 9 | import './modules/articles-index.js' 10 | import './modules/header-quick-search-presenter.js' 11 | import './modules/search.js' 12 | import './modules/search-page-filter.js' 13 | import './modules/feedback-form.js' 14 | import './modules/question-form.js' 15 | import './modules/subscribe-form.js' 16 | import './modules/code-line-numbers.js' 17 | import './modules/cookie-notification.js' 18 | import './modules/copy-code-snippet.js' 19 | import './modules/people.js' 20 | import './modules/filter-panel.js' 21 | import './modules/linked-article-navigation.js' 22 | import './modules/practices.js' 23 | import './modules/subscribe-popup.js' 24 | import './modules/person-badges.js' 25 | import './modules/person-badges-tooltip.js' 26 | import './modules/answer.js' 27 | import './modules/pwa.js' 28 | import './modules/triggers.js' 29 | -------------------------------------------------------------------------------- /src/styles/blocks/not-found.css: -------------------------------------------------------------------------------- 1 | .not-found { 2 | box-sizing: border-box; 3 | min-height: calc(90vh - 1px * var(--header-height)); 4 | padding: 20px 10px; 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | justify-content: center; 9 | text-align: center; 10 | } 11 | 12 | .not-found__logo { 13 | --logo-letter-spacing: -0.14em; 14 | --image-padding: calc(4 / 60 * 1em) calc(28 / 60 * 1em) calc(2 / 60 * 1em); 15 | --gutter: 10px; 16 | --image-font-size: calc(60px + (100vw - 375px) / (1680 - 375) * (144 - 60)); 17 | margin-bottom: var(--gutter); 18 | font-size: var(--image-font-size); 19 | } 20 | 21 | .not-found__description { 22 | margin-top: 0; 23 | margin-bottom: 0; 24 | letter-spacing: var(--letter-spacing); 25 | font-family: var(--font-family); 26 | } 27 | 28 | @media not all and (width >= 375px) { 29 | .not-found__logo { 30 | --image-font-size: 60px; 31 | } 32 | } 33 | 34 | @media (width >= 768px) { 35 | .not-found__logo { 36 | --gutter: 20px; 37 | } 38 | } 39 | 40 | @media (width >= 1680px) { 41 | .not-found__logo { 42 | --image-font-size: 144px; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Doka Community 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/libs/collection-helpers/set-path.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Функция изменяет значение в объекте по пути. Эта фнукция мутирует объект. 3 | * @param {Array} Путь – последовательность ключей в объекте 4 | * @param {*} Новое значение 5 | * @returns {Object} Cтарый объект с изменённым значением по пути, если пути ещё не было, то в объект добавятся 6 | * новые ключи. 7 | */ 8 | 9 | const setPath = (path, value, obj) => { 10 | if (obj === undefined || value === undefined) { 11 | return obj 12 | } 13 | 14 | let currentValue = obj 15 | 16 | path.forEach((element, index) => { 17 | if (index < path.length - 1) { 18 | if (!(element in currentValue)) { 19 | const nextElement = path[index + 1] 20 | if (typeof nextElement === 'number') { 21 | currentValue[element] = [] 22 | } else { 23 | currentValue[element] = {} 24 | } 25 | } 26 | 27 | currentValue = currentValue[element] 28 | } else { 29 | if (value !== currentValue[element]) { 30 | currentValue[element] = value 31 | } 32 | } 33 | }) 34 | 35 | return obj 36 | } 37 | 38 | module.exports = { setPath } 39 | -------------------------------------------------------------------------------- /src/styles/blocks/article-indexes-list.css: -------------------------------------------------------------------------------- 1 | .article-indexes-list { 2 | --column-offset: 40px; 3 | margin-bottom: calc(-1 * var(--column-offset)); 4 | overflow: hidden; 5 | column-width: 250px; 6 | column-gap: 60px; 7 | } 8 | 9 | .article-indexes-list__item { 10 | overflow: hidden; 11 | display: block; 12 | padding: 5px 0 var(--column-offset) 5px; 13 | page-break-inside: avoid; 14 | break-inside: avoid; 15 | } 16 | 17 | .article-indexes-list__title { 18 | margin-top: 0; 19 | margin-bottom: 20px; 20 | font-weight: normal; 21 | font-size: var(--font-size-l); 22 | line-height: 1; 23 | } 24 | 25 | .article-indexes-list__link { 26 | display: inline-block; 27 | vertical-align: top; 28 | color: inherit; 29 | text-decoration: none; 30 | } 31 | 32 | .article-indexes-list__link::after { 33 | content: ''; 34 | margin-top: 3px; 35 | display: block; 36 | height: 4px; 37 | border-radius: 4px; 38 | background-color: var(--accent-color); 39 | } 40 | 41 | .offline .article-indexes-list__link::after { 42 | background-image: none; 43 | } 44 | 45 | .article-indexes-list__link:hover { 46 | color: var(--accent-color); 47 | } 48 | -------------------------------------------------------------------------------- /src/libs/github-contribution-stats/github-contribution-stats.js: -------------------------------------------------------------------------------- 1 | const issues = require('../../../.issues.json') 2 | 3 | const stats = issues.reduce((result, issue) => { 4 | const user = issue['user']['login'].toLowerCase() 5 | const isPullRequest = 'pull_request' in issue 6 | const pullRequestIncrement = isPullRequest ? 1 : 0 7 | const issueIncrement = !isPullRequest ? 1 : 0 8 | const pullRequestDate = isPullRequest && issue['closed_at'] ? new Date(Date.parse(issue['closed_at'])) : new Date() 9 | 10 | if (result && user in result) { 11 | result[user]['issues'] += issueIncrement 12 | result[user]['pr'] += pullRequestIncrement 13 | if (result[user]['first'] > pullRequestDate) { 14 | result[user]['first'] = pullRequestDate 15 | } 16 | return result 17 | } else { 18 | return { 19 | ...result, 20 | [user]: { 21 | issues: issueIncrement, 22 | pr: pullRequestIncrement, 23 | first: pullRequestDate, 24 | }, 25 | } 26 | } 27 | }, {}) 28 | 29 | function getAuthorContributionStats() { 30 | return stats 31 | } 32 | 33 | module.exports = { 34 | getAuthorContributionStats, 35 | } 36 | -------------------------------------------------------------------------------- /src/styles/blocks/featured-articles-list.css: -------------------------------------------------------------------------------- 1 | .featured-articles-list { 2 | --page-size: 6; 3 | --columns: 1; 4 | display: grid; 5 | grid-gap: 10px; 6 | grid-template-columns: repeat(var(--columns), 1fr); 7 | } 8 | 9 | .featured-articles-list__item { 10 | display: grid; 11 | } 12 | 13 | .featured-articles-list__item:not(.featured-articles-list__item--active):nth-child(n + 7) { 14 | display: none; 15 | } 16 | 17 | @media not all and (width >= 1200px) { 18 | .featured-articles-list { 19 | --page-size: 4; 20 | } 21 | 22 | .featured-articles-list__item:not(.featured-articles-list__item--active):nth-child(n + 5) { 23 | display: none; 24 | } 25 | } 26 | 27 | @media not all and (width >= 768px) { 28 | .featured-articles-list { 29 | --page-size: 3; 30 | } 31 | 32 | .featured-articles-list__item:not(.featured-articles-list__item--active):nth-child(n + 4) { 33 | display: none; 34 | } 35 | } 36 | 37 | @media (width >= 768px) { 38 | .featured-articles-list { 39 | --columns: 2; 40 | } 41 | } 42 | 43 | @media (width >= 1200px) { 44 | .featured-articles-list { 45 | --columns: 3; 46 | grid-gap: 20px; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/transforms/callout-transform.js: -------------------------------------------------------------------------------- 1 | const emojiRegex = require('emoji-regex') 2 | 3 | const calloutTemplate = (icon, content) => ` 4 | 8 | ` 9 | 10 | /** 11 | * @param {Window} window 12 | */ 13 | module.exports = function (window) { 14 | window.document 15 | .querySelector('.article__content-inner') 16 | ?.querySelectorAll('aside:not([class])') 17 | ?.forEach((asideElement) => { 18 | let icon 19 | let textContent = asideElement.textContent.trim() 20 | let innerHTML = asideElement.innerHTML 21 | 22 | const emojiRegExp = emojiRegex() 23 | const firstEmojiSymbol = textContent.match(emojiRegExp)?.[0] 24 | 25 | if (textContent.startsWith(firstEmojiSymbol)) { 26 | icon = firstEmojiSymbol 27 | innerHTML = innerHTML.replace(emojiRegExp, '') 28 | } 29 | 30 | const tempElement = window.document.createElement('div') 31 | tempElement.innerHTML = calloutTemplate(icon, innerHTML) 32 | asideElement.replaceWith(tempElement.firstElementChild) 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /src/includes/blocks/article-image.njk: -------------------------------------------------------------------------------- 1 | {% macro articleImage(class, cover, authors) %} 2 | {% set var = value %} 3 |
4 | 5 | {% if cover.mobile %} 6 | 7 | {% endif %} 8 | {{ cover.alt }} 9 | 10 | {% if (authors and authors.length > 0) %} 11 |
12 | Иллюстрация: {{ authorsList(authors=authors) }} 13 |
14 | {% endif %} 15 |
16 | {% endmacro %} 17 | 18 | {% macro articleAuthor(name, url) %} 19 | {%- if url -%} 20 | {{ name }} 21 | {%- else -%} 22 | {{ name }} 23 | {%- endif -%} 24 | {% endmacro %} 25 | 26 | {% macro authorsList(authors) %} 27 | {% set joinSymbol = joiner() %} 28 | {% for author in authors -%} 29 | {{ joinSymbol() }} {{ articleAuthor(name=author.data.name, url='/people/' + author.fileSlug + '/') }} 30 | {%- endfor %} 31 | {% endmacro %} 32 | -------------------------------------------------------------------------------- /config/constants.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ path: '.env' }) 2 | 3 | const DEFAULT_ENVS = { 4 | BASE_URL: 'https://doka.guide', 5 | SECTIONS: 'html, css, js, a11y, tools, recipes', 6 | CONTENT_REP_GITHUB: 'https://github.com/doka-guide/content.git', 7 | CONTENT_HOT_BACKLOG: 'https://github.com/doka-guide/content/milestone/22', 8 | CONTENT_REP_FOLDERS: 'html, css, js, a11y, tools, recipes, people, pages, settings, interviews', 9 | PATH_TO_CONTENT: '../content', 10 | DOKA_ORG: 'DOKA_ORG', 11 | PLATFORM_REP_GITHUB_URL: 'https://github.com/doka-guide/platform', 12 | CONTENT_REP_GITHUB_URL: 'https://github.com/doka-guide/content', 13 | } 14 | 15 | function getEnv(envKey) { 16 | return process.env[envKey] || DEFAULT_ENVS[envKey] 17 | } 18 | 19 | module.exports = { 20 | baseUrl: getEnv('BASE_URL'), 21 | mainSections: getEnv('SECTIONS').split(', '), 22 | contentRepGithub: getEnv('CONTENT_REP_GITHUB'), 23 | contentRepFolders: getEnv('CONTENT_REP_FOLDERS').split(', '), 24 | defaultPathToContent: getEnv('PATH_TO_CONTENT'), 25 | dokaOrgLink: getEnv('DOKA_ORG'), 26 | platformRepLink: getEnv('PLATFORM_REP_GITHUB_URL'), 27 | contentRepLink: getEnv('CONTENT_REP_GITHUB_URL'), 28 | contentHotBacklogLink: getEnv('CONTENT_HOT_BACKLOG'), 29 | } 30 | -------------------------------------------------------------------------------- /src/styles/blocks/callout.css: -------------------------------------------------------------------------------- 1 | .callout { 2 | --padding: 10px; 3 | --code-lines-background: hsl(var(--color-fade)); 4 | display: flex; 5 | flex-wrap: wrap; 6 | align-items: baseline; 7 | gap: 10px; 8 | box-sizing: border-box; 9 | padding: 10px; 10 | overflow-wrap: break-word; 11 | border-radius: 6px; 12 | background-color: hsl(var(--color-fade)); 13 | } 14 | 15 | .callout__icon { 16 | flex: 0 0 auto; 17 | } 18 | 19 | .callout__content { 20 | --background-code-color: hsl(var(--color-base-background)); 21 | --code-lang-lable-background: var(--background-code-color); 22 | display: flex; 23 | flex-direction: column; 24 | flex: 1 1 320px; 25 | gap: 5px; 26 | } 27 | 28 | .callout__content > * { 29 | max-width: 100%; 30 | } 31 | 32 | .callout__content > *:first-child { 33 | margin-top: 0; 34 | } 35 | 36 | .callout__content > *:last-child { 37 | margin-bottom: 0; 38 | } 39 | 40 | .callout__content p + p { 41 | margin-top: 0.25em; 42 | } 43 | 44 | @media (width >= 768px) { 45 | .callout { 46 | padding-right: 20px; 47 | } 48 | } 49 | 50 | @media (width >= 1024px) { 51 | .callout { 52 | padding: 20px; 53 | } 54 | } 55 | 56 | @media (width >= 1680px) { 57 | .callout { 58 | padding-right: 3em; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/scripts/libs/__tests__/toc-text-crop-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | const { MAX_LENGTH, clipContent } = require('../../modules/toc-text-crop.js') 6 | const headersTemplate = [ 7 | 'Фильдеперсовый Константинопольский шпалоукладчик звукоизвлекает сложносочинённые турбопропизоляционные ноктюрны', 8 | 'Это самое обычное предложение, полная длина которого составляет завораживающее значение 90', 9 | 'Сколько солнечных дней в году в Москве? Лучше не знать!', 10 | ' Рыжий кот не смог перепрыгнуть забор из-за избыточного веса ', 11 | ] 12 | const dom = new JSDOM( 13 | ` 14 | 15 | ${headersTemplate[0]} 16 | ${headersTemplate[1]} 17 | ${headersTemplate[2]} 18 | ${headersTemplate[3]} 19 | ` 20 | ) 21 | 22 | test('обрезка длины заголовка секции в боковой навигации', () => { 23 | const tocLinks = dom.window.document.querySelectorAll('.toc__link') 24 | 25 | clipContent(tocLinks, MAX_LENGTH) 26 | 27 | tocLinks.forEach((link) => { 28 | const linkText = link.textContent.trim().replace(/\s+/g, ' ') 29 | 30 | expect(linkText.length).toBeLessThanOrEqual(90) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /src/styles/blocks/person.css: -------------------------------------------------------------------------------- 1 | .person { 2 | display: flex; 3 | gap: 0.33em; 4 | align-items: flex-start; 5 | font-size: var(--font-size-l); 6 | } 7 | 8 | .person__avatar { 9 | --avatar-size: calc(41 / 30 * 1em); 10 | flex: 0 0 auto; 11 | } 12 | 13 | .person__main { 14 | flex: 1 1 auto; 15 | display: flex; 16 | flex-wrap: wrap; 17 | gap: 0.2em; 18 | align-items: baseline; 19 | } 20 | 21 | .person__name { 22 | flex: 0 1 auto; 23 | line-height: calc(4 / 3); 24 | } 25 | 26 | .person__name::after { 27 | content: ','; 28 | margin-left: 0.15em; 29 | font-size: 0.77em; 30 | } 31 | 32 | .person__link { 33 | text-underline-offset: 0.2em; 34 | } 35 | 36 | .person__stat { 37 | flex: 0 0 auto; 38 | font-size: var(--font-size-m); 39 | } 40 | 41 | .person__indicators { 42 | position: relative; 43 | top: 0.06em; 44 | display: flex; 45 | align-items: center; 46 | } 47 | 48 | .person__indicator { 49 | --border-size: 2px; 50 | flex: 0 0 auto; 51 | width: 0.3em; 52 | height: 0.3em; 53 | border-radius: 50%; 54 | border: var(--border-size) solid var(--color-background); 55 | background-color: var(--accent-color); 56 | } 57 | 58 | .person__indicator:not(:first-child) { 59 | margin-left: calc(var(--border-size) * -3); 60 | } 61 | -------------------------------------------------------------------------------- /src/transforms/code-classes-transform.js: -------------------------------------------------------------------------------- 1 | // расстановка классов на инлайновые блоки с кодом 2 | /** 3 | * @param {Window} window 4 | */ 5 | module.exports = function (window) { 6 | // добавление классов на блоки `code` внутри заголовков 7 | { 8 | const classMap = { 9 | 'articles-group__link': 'articles-group__code', 10 | 'articles-group__title': 'articles-group__code', 11 | article__title: 'article__title-code', 12 | 'social-card__title': 'social-card__title-code', 13 | 'featured-article': 'featured-article__code', 14 | 'index-group-list__link': 'index-group-list__code', 15 | header__title: 'header__title-code', 16 | article__description: 'article__description-code', 17 | 'article-heading': 'article-heading__code', 18 | figure__caption: 'figure__caption-code', 19 | 'linked-article': 'linked-article__code', 20 | } 21 | 22 | for (const [parentClass, codeClass] of Object.entries(classMap)) { 23 | window.document.querySelectorAll(`.${parentClass}`).forEach((parentElement) => { 24 | parentElement.querySelectorAll('code').forEach((codeElement) => { 25 | codeElement.classList.add(codeClass, 'code-fix', 'font-theme', 'font-theme--code') 26 | }) 27 | }) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/styles/blocks/float-button.css: -------------------------------------------------------------------------------- 1 | .float-button { 2 | appearance: none; 3 | position: relative; 4 | margin: 0; 5 | padding: 0; 6 | border: 0; 7 | min-width: 48px; 8 | min-height: 48px; 9 | background-color: transparent; 10 | cursor: pointer; 11 | } 12 | 13 | .float-button__icons { 14 | --stroke-opacity: 0.3; 15 | --stroke-color: hsl(var(--color-base-text) / var(--stroke-opacity)); 16 | visibility: visible; 17 | position: absolute; 18 | left: 0; 19 | right: 0; 20 | bottom: 30px; 21 | margin: auto; 22 | width: 48px; 23 | height: 48px; 24 | border: 1px solid var(--stroke-color); 25 | border-radius: 50%; 26 | color: var(--color-text); 27 | background-color: var(--color-background); 28 | box-shadow: 0 4px 4px rgb(0 0 0 / 25%); 29 | } 30 | 31 | .float-button__icon { 32 | position: absolute; 33 | top: 0; 34 | left: 0; 35 | transform: scale(calc(var(--on) + var(--off) * 1.5)); 36 | opacity: var(--on); 37 | width: 100%; 38 | height: 100%; 39 | transition: transform 0.5s, opacity 0.5s; 40 | } 41 | 42 | .float-button__icon--close { 43 | --on: var(--is-filter-open); 44 | --off: calc(1 - var(--is-filter-open)); 45 | } 46 | 47 | .float-button__icon--open { 48 | --on: calc(1 - var(--is-filter-open)); 49 | --off: var(--is-filter-open); 50 | } 51 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Дока", 3 | "description": "Путь в разработку", 4 | "categories": [ 5 | "education" 6 | ], 7 | "lang": "ru", 8 | "dir": "ltr", 9 | "id": "/", 10 | "start_url": "/", 11 | "theme_color": "#1a1a1a", 12 | "background_color": "#ffffff", 13 | "display": "standalone", 14 | "orientation": "natural", 15 | "icons": [ 16 | { 17 | "src": "/images/icons/96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/images/icons/144x144.png", 23 | "sizes": "144x144", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "/images/icons/180x180.png", 28 | "sizes": "180x180", 29 | "type": "image/png" 30 | }, 31 | { 32 | "src": "/images/icons/192x192.png", 33 | "sizes": "192x192", 34 | "type": "image/png" 35 | }, 36 | { 37 | "src": "/images/icons/256x256.png", 38 | "sizes": "256x256", 39 | "type": "image/png" 40 | }, 41 | { 42 | "src": "/images/icons/512x512.png", 43 | "sizes": "512x512", 44 | "type": "image/png" 45 | }, 46 | { 47 | "src": "/images/icons/maskable.png", 48 | "sizes": "512x512", 49 | "type": "image/png", 50 | "purpose": "maskable" 51 | } 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /src/scripts/core/search-commons.js: -------------------------------------------------------------------------------- 1 | export const MIN_SEARCH_SYMBOLS = 3 2 | 3 | export const SYMBOL_LIMIT = 150 4 | 5 | export const SEARCHABLE_SHORT_WORDS = new Set([ 6 | // HTML 7 | 'a', 8 | 'b', 9 | 'br', 10 | 'dd', 11 | 'dl', 12 | 'dt', 13 | 'em', 14 | 'h1', 15 | 'h2', 16 | 'h3', 17 | 'h4', 18 | 'h5', 19 | 'h6', 20 | 'hr', 21 | 'i', 22 | 'li', 23 | 'ol', 24 | 'p', 25 | 'q', 26 | 'rb', 27 | 'rp', 28 | 'rt', 29 | 's', 30 | 'td', 31 | 'th', 32 | 'tr', 33 | 'tt', 34 | 'u', 35 | 'ul', 36 | // CSS 37 | 'ch', 38 | 'cm', 39 | 'em', 40 | 'ex', 41 | 'ic', 42 | 'in', 43 | 'is', 44 | 'lh', 45 | 'mm', 46 | 'ms', 47 | 'pc', 48 | 'pt', 49 | 'px', 50 | 's', 51 | 'vh', 52 | 'vw', 53 | // JS 54 | 'if', 55 | 'of', 56 | ]) 57 | 58 | export function processHits(searchObject) { 59 | if (searchObject) { 60 | return searchObject.map((articleObject) => { 61 | return { 62 | originalTitle: articleObject.title, 63 | title: articleObject.title, 64 | summary: articleObject.fragments ? articleObject.fragments : [], 65 | url: `${articleObject.link}`, 66 | category: articleObject.category, 67 | tags: articleObject.tags, 68 | } 69 | }) 70 | } else { 71 | return [] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/views/sitemap.11tydata.js: -------------------------------------------------------------------------------- 1 | const { baseUrl, mainSections } = require('../../config/constants') 2 | 3 | module.exports = { 4 | permalink: '/sitemap.xml', 5 | 6 | eleventyExcludeFromCollections: true, 7 | 8 | eleventyComputed: { 9 | pages: function (data) { 10 | const { collections } = data 11 | 12 | const collectionsItems = [...collections['pages'], ...collections['docs'], ...collections['specials']].map( 13 | (item) => { 14 | const pathName = item.filePathStem.replace('index', '') 15 | const url = `${baseUrl}${pathName}` 16 | 17 | const dateField = item.data.updatedAt || item.data.createdAt 18 | const date = dateField ? new Date(dateField) : item.data.page.date || new Date() 19 | 20 | return { 21 | url, 22 | date, 23 | } 24 | } 25 | ) 26 | 27 | const standalonePages = [ 28 | // главная страница 29 | { 30 | url: `${baseUrl}/`, 31 | date: new Date(), 32 | }, 33 | // страница с индексами категорий 34 | ...mainSections.map((section) => ({ 35 | url: `${baseUrl}/${section}/`, 36 | date: new Date(), 37 | })), 38 | ] 39 | 40 | return [...standalonePages, ...collectionsItems] 41 | }, 42 | }, 43 | } 44 | -------------------------------------------------------------------------------- /src/styles/blocks/theme-toggle.css: -------------------------------------------------------------------------------- 1 | .theme-toggle { 2 | --bg-lightness: calc(var(--is-light-theme-on) * 87% + var(--is-dark-theme-on) * 20%); 3 | 4 | display: inline-flex; 5 | align-items: baseline; 6 | padding: 2px; 7 | border-radius: 2em; 8 | background-color: hsl(0 0% var(--bg-lightness)); 9 | } 10 | 11 | .theme-toggle__item { 12 | position: relative; 13 | } 14 | 15 | .theme-toggle__control { 16 | margin: 0; 17 | -webkit-appearance: none; 18 | appearance: none; 19 | position: absolute; 20 | z-index: 1; 21 | top: 0; 22 | left: 0; 23 | box-sizing: border-box; 24 | width: 100%; 25 | height: 100%; 26 | border: 0; 27 | border-radius: 2em; 28 | background: none; 29 | cursor: pointer; 30 | } 31 | 32 | .theme-toggle__text { 33 | opacity: 0.7; 34 | margin: 0; 35 | appearance: none; 36 | display: block; 37 | padding: 0.4em 0.67em; 38 | border: 0; 39 | font: inherit; 40 | line-height: 1; 41 | color: inherit; 42 | background-color: transparent; 43 | border-radius: 2em; 44 | transition: opacity 0.2s; 45 | } 46 | 47 | .theme-toggle__control:focus + .theme-toggle__text, 48 | .theme-toggle__control:hover + .theme-toggle__text { 49 | opacity: 1; 50 | } 51 | 52 | .theme-toggle__control:checked + .theme-toggle__text { 53 | opacity: 1; 54 | background-color: var(--color-background); 55 | } 56 | -------------------------------------------------------------------------------- /src/scripts/modules/articles-gallery.js: -------------------------------------------------------------------------------- 1 | function init() { 2 | const gallery = document.querySelector('.articles-gallery') 3 | 4 | if (!gallery) { 5 | return 6 | } 7 | 8 | const button = gallery.querySelector('.articles-gallery__more-button') 9 | 10 | if (!button) { 11 | return 12 | } 13 | 14 | const list = gallery.querySelector('.featured-articles-list') 15 | const items = Array.from(list?.querySelectorAll('.featured-articles-list__item') || []) 16 | 17 | if (items.length === 0) { 18 | return 19 | } 20 | 21 | const activeClass = 'featured-articles-list__item--active' 22 | const linkClass = '.featured-article__link' 23 | 24 | const pageSize = parseInt(getComputedStyle(list).getPropertyValue('--page-size'), 10) || 1 25 | let lastItemIndex = pageSize 26 | 27 | function loadItems() { 28 | items.slice(lastItemIndex, lastItemIndex + pageSize).forEach((item, index) => { 29 | item.classList.add(activeClass) 30 | 31 | if (index === 0) { 32 | item.querySelector(linkClass).focus() 33 | } 34 | }) 35 | 36 | lastItemIndex += pageSize 37 | 38 | if (lastItemIndex >= items.length) { 39 | button.hidden = true 40 | } 41 | } 42 | 43 | button.addEventListener('click', loadItems) 44 | } 45 | 46 | try { 47 | init() 48 | } catch (error) { 49 | console.error(error) 50 | } 51 | -------------------------------------------------------------------------------- /src/views/all.njk: -------------------------------------------------------------------------------- 1 | {% from "blocks/header.njk" import header with context %} 2 | {% from "blocks/footer.njk" import footer with context %} 3 | 4 | {% set isLogoContrastColor = hasCategory %} 5 | 6 | {{ header( 7 | title=title, 8 | link=permalink, 9 | isLogoContrastColor=isLogoContrastColor 10 | ) }} 11 | 12 |
13 | 35 |
36 | 37 | {{ footer() }} 38 | -------------------------------------------------------------------------------- /src/transforms/demo-external-link-transform.js: -------------------------------------------------------------------------------- 1 | // добавляет ссылки для открытия демок в новом окне 2 | /** 3 | * 4 | * @param {Window} window 5 | * @param {string | null} content 6 | * @param {string} outputPath 7 | */ 8 | module.exports = function (window, content, outputPath) { 9 | const articleContent = window.document.querySelector('.article__content-inner') 10 | 11 | if (!articleContent) { 12 | return 13 | } 14 | 15 | const iframes = articleContent.querySelectorAll('iframe') 16 | 17 | const baseSourcePath = outputPath.replace('dist/', '').replace('/index.html', '') 18 | 19 | Array.from(iframes) 20 | .filter((iframe) => iframe.getAttribute('src').includes('demos/')) 21 | .forEach((iframe) => { 22 | const iframeSourceLink = iframe.getAttribute('src').replace('./', '') 23 | const wrapper = window.document.createElement('figure') 24 | wrapper.classList.add('figure') 25 | iframe.classList.add('figure__content') 26 | wrapper.innerHTML = ` 27 | ${iframe.outerHTML} 28 |
29 | 30 | Открыть демо в новой вкладке 31 | 32 |
33 | ` 34 | iframe.replaceWith(wrapper) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /src/includes/blocks/search.njk: -------------------------------------------------------------------------------- 1 | 47 | -------------------------------------------------------------------------------- /src/scripts/modules/last-update.js: -------------------------------------------------------------------------------- 1 | export function init() { 2 | const DAY_DURATION = 86400 3 | const time = document.querySelector('[data-relative-time]') 4 | 5 | if (!time) { 6 | return 7 | } 8 | 9 | function getPassedTime(postTime) { 10 | return Math.abs((new Date() - postTime) / 1000) // Вычисляет, сколько прошло времени с момента публикации в секундах 11 | } 12 | 13 | function timeFormatter(passedTime) { 14 | const converter = [ 15 | ['hour', 3600], 16 | ['minute', 60], 17 | ['second', 1], 18 | ] 19 | const timeTemplate = new Intl.RelativeTimeFormat('ru', { 20 | localeMatcher: 'best fit', 21 | numeric: 'always', 22 | style: 'long', 23 | }) 24 | const [unit, seconds] = converter.find(([, seconds]) => seconds <= passedTime) 25 | const convertedTime = Math.round(passedTime / seconds) 26 | 27 | return timeTemplate.format(-convertedTime, unit) 28 | } 29 | 30 | const postTime = new Date(time.dateTime) 31 | const passedTime = getPassedTime(postTime) 32 | 33 | if (passedTime < DAY_DURATION) { 34 | time.textContent = timeFormatter(passedTime) // Преобразует прошедшее время с момента публикации в секундах в языковую запись 35 | } 36 | } 37 | 38 | try { 39 | init() 40 | } catch (error) { 41 | console.error(`Не удалось вычислить, сколько прошло времени с момента публикации: ${error}`) 42 | } 43 | -------------------------------------------------------------------------------- /src/images/publisher-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/scripts/modules/person-badges-tooltip.js: -------------------------------------------------------------------------------- 1 | // Слушаем события с тултипами 2 | function initTooltip(tooltipContainer) { 3 | const trigger = tooltipContainer.querySelector('.person-badges__default-image') 4 | const tooltip = tooltipContainer.querySelector('.person-badges__pop-up-container') 5 | 6 | // Показываем тултип при наведении курсора и при фокусе 7 | tooltipContainer.addEventListener('mouseenter', () => { 8 | showTooltip(tooltip) 9 | }) 10 | trigger.addEventListener('focus', () => { 11 | showTooltip(tooltip) 12 | }) 13 | 14 | // Прячем тултип, когда курсор не на значке и фокус на другом элементе 15 | tooltipContainer.addEventListener('mouseleave', () => { 16 | hideTooltip(tooltip) 17 | }) 18 | trigger.addEventListener('blur', () => { 19 | hideTooltip(tooltip) 20 | }) 21 | 22 | // Скрываем тултип при нажатии на Esc 23 | trigger.addEventListener('keydown', (event) => { 24 | if (event.key === 'Escape') { 25 | hideTooltip(tooltip) 26 | } 27 | }) 28 | } 29 | 30 | function showTooltip(tooltip) { 31 | tooltip.style.display = 'grid' 32 | } 33 | 34 | function hideTooltip(tooltip) { 35 | tooltip.style.display = 'none' 36 | } 37 | 38 | window.addEventListener('load', () => { 39 | // Вызываем функцию 40 | const tooltips = document.querySelectorAll('.person-badges__sign') 41 | 42 | tooltips.forEach((tooltip) => { 43 | initTooltip(tooltip) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /src/styles/blocks/person-links-list.css: -------------------------------------------------------------------------------- 1 | .person-links-list { 2 | display: grid; 3 | grid-gap: 10px; 4 | font-size: var(--font-size-s); 5 | line-height: 1.47; 6 | font-family: var(--font-family); 7 | letter-spacing: var(--letter-spacing); 8 | border-bottom: 1px solid var(--color-text); 9 | border-top: 1px solid var(--color-text); 10 | padding: 20px 0; 11 | } 12 | 13 | .person-links-list__link { 14 | display: inline-flex; 15 | align-items: baseline; 16 | vertical-align: top; 17 | text-decoration: none; 18 | background-repeat: repeat-x; 19 | background-image: 20 | linear-gradient( 21 | 90deg, 22 | hsl(var(--color-base-text) / var(--stroke-opacity)), 23 | hsl(var(--color-base-text) / var(--stroke-opacity)) 24 | ); 25 | background-size: 100% var(--stroke-width, 1px); 26 | background-position: 0 100%; 27 | word-break: break-word; 28 | } 29 | 30 | .person-links-list__icon { 31 | flex-shrink: 0; 32 | } 33 | 34 | .person-links-list__icon:nth-of-type(1) { 35 | margin-right: 12px; 36 | } 37 | 38 | .person-links-list__icon:nth-of-type(2) { 39 | margin-right: 5px; 40 | } 41 | 42 | .person-links-list__link--own .person-links-list__icon:nth-of-type(1) { 43 | margin-right: 3px; 44 | } 45 | 46 | .person-links-list__icon .twitter-icon { 47 | fill: #1d9bf0; 48 | } 49 | 50 | @media (width >= 1024px) { 51 | .person-links-list { 52 | border: none; 53 | padding: 0; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/scripts/modules/snow-toggle.js: -------------------------------------------------------------------------------- 1 | const storageKey = 'snow' 2 | const snow = document.querySelector('.snow') 3 | let snowflakes = document.querySelectorAll('.snow__flake') 4 | const snowToggle = document.querySelector('.snow-toggle') 5 | 6 | function getRndInteger(min, max) { 7 | return Math.floor(Math.random() * (max - min + 1)) + min 8 | } 9 | 10 | function getRndFloat(min, max) { 11 | return (Math.random() * (max - min) + min).toFixed(1) 12 | } 13 | 14 | snowflakes.forEach((snowflake) => { 15 | snowflake.style.fontSize = getRndFloat(0.7, 1.5) + 'em' 16 | snowflake.style.animationDuration = getRndInteger(20, 30) + 's' 17 | snowflake.style.animationDelay = getRndInteger(-1, snowflakes.length / 2) + 's' 18 | }) 19 | 20 | function changeSnowAnimation(animationName) { 21 | snow.style.setProperty('--animation-name', animationName) 22 | } 23 | 24 | snowToggle.addEventListener('change', (event) => { 25 | changeSnowAnimation(event.target.value) 26 | localStorage.setItem(storageKey, event.target.value) 27 | }) 28 | 29 | document.addEventListener('DOMContentLoaded', () => { 30 | let currentStorage = localStorage.getItem(storageKey) 31 | 32 | if (currentStorage) { 33 | snowToggle.querySelector(`.snow-toggle__control[value='${currentStorage}']`).checked = true 34 | } 35 | 36 | changeSnowAnimation(currentStorage) 37 | 38 | window.addEventListener('storage', () => { 39 | changeSnowAnimation(localStorage.getItem(storageKey)) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | js: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Загрузка платформы 14 | uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | - name: Установка необходимых зависимостей 19 | run: npm install --save-dev eslint-config-prettier eslint-plugin-prettier prettier 20 | 21 | - name: Проверка линтером JS 22 | run: npx eslint '*.js' 23 | css: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Загрузка платформы 27 | uses: actions/checkout@v4 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version: 20 31 | - name: Кэширование модулей 32 | uses: actions/cache@v4 33 | env: 34 | cache-name: cache-node-modules 35 | with: 36 | path: ~/.npm 37 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 38 | restore-keys: | 39 | ${{ runner.os }}-build-${{ env.cache-name }}- 40 | ${{ runner.os }}-build- 41 | ${{ runner.os }}- 42 | - name: Установка необходимых зависимостей 43 | run: npm install --save-dev stylelint-config-standard 44 | - name: Проверка линтером CSS 45 | run: npx stylelint 'src/styles/**/*.css' 46 | -------------------------------------------------------------------------------- /src/markdown-it.js: -------------------------------------------------------------------------------- 1 | // Настраивает markdown-it 2 | const markdownIt = require('markdown-it') 3 | 4 | module.exports = () => { 5 | const md = markdownIt({ 6 | html: true, 7 | breaks: true, 8 | linkify: true, 9 | highlight: function (str, lang) { 10 | const content = md.utils.escapeHtml(str) 11 | const LANG_ALIASES = { 12 | javascript: 'js', 13 | nginxconf: 'nginx', 14 | } 15 | 16 | if (lang in LANG_ALIASES) { 17 | lang = LANG_ALIASES[lang] 18 | } 19 | 20 | return lang ? `
${content}
` : `
${content}
` 21 | }, 22 | }) 23 | 24 | const defaultRenderer = md.renderer.rules.html_block 25 | md.renderer.rules.html_block = function (tokens, idx, options, env, self) { 26 | const token = tokens[idx] 27 | const blockContent = token.content.trim() 28 | // отдельно обрабатываем html-блоки с тегом видео 29 | if (blockContent.startsWith('')) { 30 | const selectVideoAndCaption = /([\s\S]+<\/video>)([\s\S]+)$/i 31 | const [, videoHtml, caption] = blockContent.match(selectVideoAndCaption) 32 | if (videoHtml && caption) { 33 | return `
34 | ${videoHtml.trim()} 35 |
36 | ${md.renderInline(caption.trim())} 37 |
38 |
` 39 | } 40 | } 41 | 42 | return defaultRenderer(tokens, idx, options, env, self) 43 | } 44 | 45 | return md 46 | } 47 | -------------------------------------------------------------------------------- /src/styles/blocks/button.css: -------------------------------------------------------------------------------- 1 | .button { 2 | --color: var(--color-text); 3 | --background: transparent; 4 | --border-color: var(--color); 5 | --bg-base-opacity: 6 | calc( 7 | var(--is-light-theme-on) * 0.06 + 8 | var(--is-dark-theme-on) * 0.12 9 | ); 10 | margin: 0; 11 | position: relative; 12 | z-index: 0; 13 | appearance: none; 14 | overflow: hidden; 15 | display: inline-block; 16 | box-sizing: border-box; 17 | padding: 0.5em 1.33em; 18 | border: 1px solid var(--border-color); 19 | font: inherit; 20 | text-decoration: none; 21 | color: var(--color); 22 | background-color: var(--background); 23 | border-radius: 6px; 24 | cursor: pointer; 25 | user-select: none; 26 | } 27 | 28 | .button::before { 29 | content: ''; 30 | opacity: 0; 31 | position: absolute; 32 | inset: -1px; 33 | background-color: currentColor; 34 | transition: opacity 0.2s; 35 | } 36 | 37 | .button:disabled { 38 | pointer-events: none; 39 | } 40 | 41 | .button:hover::before { 42 | opacity: var(--bg-base-opacity); 43 | } 44 | 45 | .button:active::before { 46 | opacity: calc(var(--bg-base-opacity) * 2); 47 | transition: none; 48 | } 49 | 50 | .button--active, 51 | .button--invert { 52 | --color: var(--color-background); 53 | --background: var(--color-text); 54 | --border-color: var(--background); 55 | --bg-base-opacity: 56 | calc( 57 | var(--is-light-theme-on) * 0.16 + 58 | var(--is-dark-theme-on) * 0.1 59 | ); 60 | } 61 | 62 | .button--round { 63 | border-radius: 2em; 64 | } 65 | -------------------------------------------------------------------------------- /src/scripts/modules/articles-index.js: -------------------------------------------------------------------------------- 1 | function init() { 2 | const rootBlock = document.querySelector('.index-block') 3 | 4 | if (!rootBlock) { 5 | return 6 | } 7 | 8 | const filter = rootBlock.querySelector('.index-block__filter-control') 9 | const sections = rootBlock.querySelectorAll('.index-section') 10 | 11 | if (!filter && !sections) { 12 | return 13 | } 14 | 15 | const PARAM_NAME = 'view' 16 | 17 | const VIEWS = { 18 | THEMES: 'themes', 19 | ALPHABET: 'alphabet', 20 | } 21 | 22 | function applyView(currentView) { 23 | for (const section of sections) { 24 | section.hidden = section.id !== currentView 25 | } 26 | } 27 | 28 | function setURLSearchParams(currentView) { 29 | const params = new URLSearchParams({ 30 | [PARAM_NAME]: currentView, 31 | }) 32 | history.replaceState(null, null, `?${params}`) 33 | } 34 | 35 | filter.addEventListener('change', (event) => { 36 | const { value: view } = event.target 37 | 38 | if (!view) { 39 | return 40 | } 41 | 42 | applyView(view) 43 | setURLSearchParams(view) 44 | }) 45 | 46 | const params = new URLSearchParams(window.location.search) 47 | applyView(params.get(PARAM_NAME) || VIEWS.THEMES) 48 | 49 | // Chromium иногда не прокручивает до anchor-блока, если в адресной строке нажать enter 50 | const hash = window.location.hash 51 | if (hash) { 52 | window.addEventListener('load', () => { 53 | document.querySelector(hash)?.scrollIntoView() 54 | }) 55 | } 56 | } 57 | 58 | init() 59 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # Что можно делать с Докой 2 | 3 | **Обратите внимание:** здесь вы прочитаете упрощённую русскоязычную версию лицензии. Все юридически значимые документы лежат в своих репозиториях и доступны на английском языке. 4 | 5 | ## Лицензия на код платформы 6 | 7 | Платформа создана и работает на базе [Eleventy](https://www.11ty.dev), исходный код которой распространяется по лицензии MIT. 8 | 9 | Вы можете делать с кодом платформы что угодно (сохраняя уведомление об авторском праве), но мы или те, кто работал до нас, не несут за этот код никакой ответственности. 10 | 11 | Почитать [текст лицензии](../LICENSE.md) 12 | 13 | ## Лицензия на шрифты 14 | 15 | Для Доки мы используем шрифты Graphik и Spot Mono. Наши копии шрифтов можно использовать только на сайте [doka.guide](https://doka.guide/). 16 | 17 | Авторские права на шрифт Graphik во всех начертаниях принадлежат ООО «Тайп Тудэй». Шрифт предоставлен Доке по лицензии для веб-шрифтов. Подробности читайте в тексте лицензий [в репозитории](../src/fonts/graphik/LICENSE.md) или [на официальном сайте](https://type.today/en/license/today/web). 18 | 19 | Авторские права на шрифт Spot Mono принадлежат Schick Toikka GbR. Шрифт предоставлен Доке по лицензии для веб-шрифтов. Подробности читайте в тексте лицензий [в репозитории](../src/fonts/spot-mono/LICENSE.md) или [на официальном сайте](https://www.schick-toikka.com/licenses). 20 | 21 | ## Прочие случаи 22 | 23 | Если мы используем какие-либо материалы по другим лицензиям или из общественного достояния, мы обязательно об этом сообщим и приложим нужные документы. 24 | -------------------------------------------------------------------------------- /src/includes/blocks/logo.njk: -------------------------------------------------------------------------------- 1 | {% macro logo( 2 | isLink, 3 | isContrastColor, 4 | class, 5 | letters, 6 | isImageHidden = false) 7 | %} 8 | {% set logoWrapper = linkLogo if isLink else simpleLogo %} 9 | {% set tag = 'a' if isLink else 'div' %} 10 | {% set attrs = 'href=/' if isLink %} 11 | {% set classes %} 12 | logo 13 | {{ 'logo--color-contrast' if isContrastColor else 'logo--color-fade' }} 14 | font-theme 15 | font-theme--code 16 | {{ class if class }} 17 | {% endset %} 18 | 19 | {% set defaultLetters %} 20 | UU 21 | {% endset %} 22 | {% set letters = letters if letters else defaultLetters %} 23 | 24 | <{{tag}} class="{{ classes }}" {{ attrs }}> 25 | {% if (not isImageHidden) %} 26 | 37 | {% endif %} 38 | 40 | Дока 41 | 42 | 43 | {% endmacro %} 44 | -------------------------------------------------------------------------------- /src/includes/subscribe-popup.njk: -------------------------------------------------------------------------------- 1 | 29 | -------------------------------------------------------------------------------- /src/includes/practices.njk: -------------------------------------------------------------------------------- 1 | {% macro practiceAuthorName(name, url) %} 2 | {% if url %} 3 | {{ name }} 4 | {% else %} 5 | {{ name }} 6 | {% endif %} 7 | {% endmacro %} 8 | 9 | {% if practices.length > 0 %} 10 |
11 | {{ practicesAuthors.length }} 12 |

13 | На практике 14 |

15 | 16 | {% for practice in practices %} 17 | {% set practiceAuthor = collections.peopleById[practice.fileSlug] %} 18 | 19 |
20 |

21 | 22 | {{ practiceAuthorName( 23 | name=(practiceAuthor.data.name or practice.fileSlug), 24 | url='/people/' + practiceAuthor.fileSlug + '/' 25 | ) }} 26 | 27 | советует 28 |

29 |
30 |
31 | {{ practice.templateContent | safe }} 32 | {% if practice.isLong %} 33 | 36 | {% endif %} 37 |
38 |
39 |
40 | {% endfor %} 41 | 42 |
43 | {% endif %} 44 | -------------------------------------------------------------------------------- /src/transforms/code-breakify-transform.js: -------------------------------------------------------------------------------- 1 | function breakify(content) { 2 | const symbols = ['.', ',', '-', '_', '=', ':', '~', '/', '\\', '?', '#', '%', '(', ')', '[', ']'] 3 | 4 | // Расстановка переносов между частями слова 5 | if (/[A-Z]/g.test(content)) { 6 | switch (true) { 7 | case /^[^A-Z]/.test(content): 8 | content = content.replaceAll(/[A-Z]/g, (match) => `${match}`) 9 | break 10 | case /[A-Z]{2,}/.test(content): 11 | content = content.replaceAll(/[A-Z][a-z]+/g, (match) => `${match}`) 12 | break 13 | case /[A-Z][a-z.]+[A-Z][a-z()]+/.test(content): 14 | content = content.replaceAll(/[A-Z][a-z()]+$/g, (match) => `${match}`) 15 | break 16 | default: 17 | break 18 | } 19 | } 20 | 21 | // Расстановка переносов по спецсимволам 22 | for (const symbol of symbols) { 23 | const firstSymbolWithinTags = new RegExp(`^()[\\${symbol}]()`, '') 24 | 25 | content = content.replaceAll(symbol, `${symbol}`) 26 | 27 | // Исключение для переносов первого спецсимвола в слове 28 | if (firstSymbolWithinTags.test(content)) { 29 | content = content.replace(firstSymbolWithinTags, `${symbol}`) 30 | } 31 | } 32 | 33 | return content 34 | } 35 | 36 | /** 37 | * расстановка в элементах кода 38 | * @param {Window} window 39 | */ 40 | module.exports = function (window) { 41 | const inlineCodeElements = window.document.querySelectorAll('.code-fix') 42 | 43 | for (const codeElement of inlineCodeElements) { 44 | codeElement.innerHTML = breakify(codeElement.innerHTML) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/scripts/modules/top-banner.js: -------------------------------------------------------------------------------- 1 | function init() { 2 | const banner = document.querySelector('.top-banner') 3 | const button = banner?.querySelector('.top-banner__button') 4 | const interval = 10000 5 | const visitedPagesKey = 'pages-list' 6 | const cookieNotificationKey = 'cookie-notification' 7 | const subscriptionPopupKey = 'subscription-form-status' 8 | 9 | if (!banner && !button) { 10 | return 11 | } 12 | 13 | const storageKey = 'top-banner' 14 | 15 | try { 16 | const isBannerAccepted = JSON.parse(localStorage.getItem(storageKey)) 17 | 18 | if (isBannerAccepted) { 19 | return 20 | } 21 | 22 | const visitedPages = localStorage.getItem(visitedPagesKey) 23 | const cookieNotification = localStorage.getItem(cookieNotificationKey) 24 | const subscriptionPopup = localStorage.getItem(subscriptionPopupKey) 25 | const pageList = visitedPages ? JSON.parse(visitedPages) : {} 26 | 27 | let timer = setInterval(() => { 28 | if (pageList && cookieNotification && (subscriptionPopup === 'SHOWN' || subscriptionPopup === 'CLOSED')) { 29 | for (const key in pageList) { 30 | if (pageList[key].duration >= interval * 2) { 31 | banner.hidden = false 32 | clearInterval(timer) 33 | break 34 | } 35 | } 36 | } 37 | }, interval) 38 | } catch (error) { 39 | console.error(error) 40 | } 41 | 42 | button.addEventListener( 43 | 'click', 44 | () => { 45 | banner.hidden = true 46 | localStorage.setItem(storageKey, true) 47 | }, 48 | { once: true }, 49 | ) 50 | } 51 | 52 | init() 53 | -------------------------------------------------------------------------------- /src/scripts/modules/copy-code-snippet.js: -------------------------------------------------------------------------------- 1 | function init() { 2 | const isArticle = !!document.querySelector('.article__content-inner') 3 | 4 | if (!isArticle) { 5 | return 6 | } 7 | 8 | const STATES = { 9 | IDLE: 'idle', 10 | SUCCESS: 'success', 11 | ERROR: 'error', 12 | } 13 | 14 | const MESSAGE_TIMEOUT = 5000 15 | 16 | document.addEventListener('click', (event) => { 17 | const copyButton = event.target.closest('.block-code__copy-button') 18 | 19 | if (!copyButton) { 20 | return 21 | } 22 | 23 | const rootElement = copyButton.closest('.block-code') 24 | 25 | if (!rootElement) { 26 | return 27 | } 28 | 29 | const contentElement = rootElement.querySelector('.block-code__highlight') 30 | 31 | if (!contentElement) { 32 | return 33 | } 34 | 35 | copyButton.disabled = true 36 | let isTabPressed 37 | 38 | navigator.clipboard 39 | .writeText(contentElement.textContent) 40 | .then(() => { 41 | copyButton.dataset.state = STATES.SUCCESS 42 | 43 | document.addEventListener('keydown', (event) => { 44 | if (event.key === 'Tab') { 45 | isTabPressed = true 46 | } 47 | }) 48 | }) 49 | .catch(() => { 50 | copyButton.dataset.state = STATES.ERROR 51 | }) 52 | .finally(() => { 53 | setTimeout(() => { 54 | copyButton.dataset.state = STATES.IDLE 55 | copyButton.disabled = false 56 | 57 | if (!isTabPressed) { 58 | copyButton.focus() 59 | } 60 | }, MESSAGE_TIMEOUT) 61 | }) 62 | }) 63 | } 64 | 65 | init() 66 | -------------------------------------------------------------------------------- /src/styles/blocks/tag-filter.css: -------------------------------------------------------------------------------- 1 | .tag-filter { 2 | --tag-filter-color: var(--accent-color, var(--color-text, currentColor)); 3 | position: relative; 4 | cursor: pointer; 5 | font-size: var(--font-size-m); 6 | line-height: var(--font-line-height-m); 7 | } 8 | 9 | .tag-filter__control { 10 | -webkit-appearance: none; 11 | appearance: none; 12 | margin: 0; 13 | position: absolute; 14 | left: 0; 15 | top: 0; 16 | box-sizing: border-box; 17 | width: 100%; 18 | height: 100%; 19 | cursor: pointer; 20 | border-radius: 2em; 21 | } 22 | 23 | .tag-filter__text { 24 | position: relative; 25 | z-index: 0; 26 | display: block; 27 | overflow: hidden; 28 | padding: 4px 10px; 29 | box-shadow: inset 0 0 0 2px var(--tag-filter-color); 30 | line-height: 1; 31 | text-align: center; 32 | border-radius: 2em; 33 | transition: color 125ms, background-color 125ms; 34 | user-select: none; 35 | -webkit-touch-callout: none; 36 | -webkit-user-select: none; 37 | } 38 | 39 | .tag-filter__text::before { 40 | content: ''; 41 | opacity: 0; 42 | position: absolute; 43 | z-index: -1; 44 | inset: 0; 45 | background-color: var(--tag-filter-color); 46 | transition: opacity 125ms; 47 | will-change: opacity; 48 | } 49 | 50 | .tag-filter:hover .tag-filter__text::before { 51 | opacity: 52 | calc( 53 | var(--is-light-theme-on) * 0.1 + 54 | var(--is-dark-theme-on) * 0.4 55 | ); 56 | } 57 | 58 | .tag-filter__control:checked + .tag-filter__text::before { 59 | opacity: 1; 60 | } 61 | 62 | .tag-filter--contrast .tag-filter__control:checked + .tag-filter__text { 63 | color: var(--color-background); 64 | } 65 | -------------------------------------------------------------------------------- /src/includes/blocks/baseline.njk: -------------------------------------------------------------------------------- 1 |
2 | Поддержка в браузерах: 3 |
    4 | {% for browser in baseline.keys %} 5 |
  • 6 | 7 | {% if baseline.supported[browser] %} 8 | {{ baseline.names[browser] }} {{ baseline.versions[browser] }}, поддерживается 9 | 10 | 11 | {% elif baseline.flagged[browser] %} 12 | {{ baseline.names[browser] }} {{ baseline.versions[browser] }}, за флагом 13 | 15 | {% elif baseline.preview[browser] %} 16 | {{ baseline.names[browser] }}, превью 17 | 18 | {% else %} 19 | {{ baseline.names[browser] }}, не поддерживается 20 | 21 | 22 | {% endif %} 23 |
  • 24 | {% endfor %} 25 |
26 | О Baseline 27 |
28 | -------------------------------------------------------------------------------- /src/images/badges/most-viewed-month-zeta.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/workflows/w3c-validator.yml: -------------------------------------------------------------------------------- 1 | name: W3C Validator 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | validate: 11 | runs-on: ubuntu-latest 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | PATH_TO_CONTENT: ./content 15 | steps: 16 | - name: Загрузка платформы 17 | uses: actions/checkout@v4 18 | - name: Загрузка контента 19 | uses: actions/checkout@v4 20 | with: 21 | repository: doka-guide/content 22 | path: content 23 | - name: Загрузка кеша 24 | uses: actions/checkout@v2 25 | with: 26 | repository: doka-guide/cache 27 | path: cache 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version: 20 31 | - uses: actions/setup-java@v4 32 | with: 33 | java-version: 17 34 | distribution: 'temurin' 35 | architecture: x64 36 | - name: Кэширование модулей 37 | uses: actions/cache@v3 38 | env: 39 | cache-name: cache-node-modules 40 | with: 41 | path: ~/.npm 42 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 43 | restore-keys: | 44 | ${{ runner.os }}-build-${{ env.cache-name }}- 45 | ${{ runner.os }}-build- 46 | ${{ runner.os }}- 47 | - name: Установка модулей 48 | run: npm ci 49 | - name: Копирование кеша 50 | run: cp ./cache/issues.json ./.issues.json 51 | - name: Сборка сайта 52 | run: npm run build 53 | - name: Валидация 54 | run: npx node-w3c-validator -i ./dist/**/*.html -f lint -ev 55 | -------------------------------------------------------------------------------- /src/styles/blocks/header-animation.css: -------------------------------------------------------------------------------- 1 | /* анимируем появление строки поиска, когда меню открывается */ 2 | .header__controls .header__search { 3 | transition: opacity var(--header-animation-time); 4 | } 5 | 6 | /* анимируем булочку */ 7 | .logo { 8 | --image-font-size: 0.85em; 9 | --logo-letter-spacing: -0.14em; 10 | } 11 | 12 | .logo__image { 13 | --is-animation: 0; 14 | } 15 | 16 | .logo__image::before { 17 | --colors: 18 | var(--accent-color), 19 | var(--accent-color), 20 | var(--color-css), 21 | var(--color-js), 22 | var(--color-tools), 23 | var(--color-recipes), 24 | var(--color-a11y), 25 | var(--accent-color), 26 | var(--accent-color); 27 | --parts: 8; 28 | content: ''; 29 | opacity: var(--is-animation); 30 | position: absolute; 31 | z-index: -1; 32 | left: 0; 33 | right: 0; 34 | top: 0; 35 | height: calc(var(--parts) * 100%); 36 | background-image: linear-gradient(var(--colors)); 37 | transition: opacity 0.5s; 38 | } 39 | 40 | .logo__image--animation { 41 | --is-animation: 1; 42 | } 43 | 44 | @keyframes logoAnimation { 45 | to { 46 | transform: 47 | translateY(-100%) 48 | translateY(calc(100% / (var(--parts) - 1))); 49 | } 50 | } 51 | 52 | .logo__image--animation::before { 53 | animation: logoAnimation 0.75s ease-out infinite both; 54 | } 55 | 56 | .logo__symbols { 57 | transition: opacity 0.2s; 58 | 59 | /* для устранения скачков в Safari */ 60 | transform: translateZ(0); 61 | } 62 | 63 | .logo__symbols--main { 64 | opacity: calc(1 - var(--is-animation)); 65 | } 66 | 67 | .logo__symbols--search { 68 | opacity: var(--is-animation); 69 | } 70 | 71 | .logo__eye { 72 | top: -0.08em; 73 | } 74 | 75 | .logo__nose { 76 | top: -0.015em; 77 | } 78 | -------------------------------------------------------------------------------- /src/styles/blocks/questions.css: -------------------------------------------------------------------------------- 1 | .questions__description { 2 | font-style: italic; 3 | padding: 30px 0 50px; 4 | } 5 | 6 | .question__request { 7 | display: flex; 8 | flex-direction: column; 9 | align-items: flex-start; 10 | margin-block-start: 40px; 11 | } 12 | 13 | .question__request .callout { 14 | align-self: stretch; 15 | } 16 | 17 | .content p.question__response-placeholder { 18 | margin-block-start: 4px; 19 | margin-block-end: 4px; 20 | } 21 | 22 | .question__answer > .article-heading { 23 | margin-block-start: 0; 24 | margin-block-end: 0; 25 | padding: var(--offset); 26 | padding-block-end: 0; 27 | } 28 | 29 | .questions__edit-button { 30 | font-size: var(--font-size-s); 31 | line-height: var(--font-line-height-s); 32 | } 33 | 34 | .question__request .questions__edit-button { 35 | margin-block-start: 10px; 36 | } 37 | 38 | .question__answer { 39 | margin-block-start: 10px; 40 | } 41 | 42 | @media (width >= 768px) { 43 | .content p.question__response-placeholder { 44 | margin-block-start: 7px; 45 | margin-block-end: 7px; 46 | } 47 | 48 | .question__request .questions__edit-button { 49 | margin-block-start: 14px; 50 | } 51 | 52 | .question__answer { 53 | margin-block-start: 14px; 54 | } 55 | } 56 | 57 | @media (width >= 1024px) { 58 | .question__request { 59 | margin-block-start: 50px; 60 | } 61 | 62 | .content p.question__response-placeholder { 63 | margin-block-start: 10px; 64 | margin-block-end: 10px; 65 | } 66 | 67 | .question__request .questions__edit-button { 68 | margin-block-start: 16px; 69 | } 70 | 71 | .question__answer { 72 | margin-block-start: 16px; 73 | } 74 | } 75 | 76 | @media (width >= 1680px) { 77 | .question__request { 78 | margin-block-start: 60px; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/styles/blocks/switch.css: -------------------------------------------------------------------------------- 1 | .switch { 2 | display: inline-flex; 3 | text-align: center; 4 | } 5 | 6 | .switch__item { 7 | flex: 1 1 auto; 8 | position: relative; 9 | display: flex; 10 | } 11 | 12 | .switch__input { 13 | opacity: 0; 14 | position: absolute; 15 | z-index: 1; 16 | top: 0; 17 | left: 0; 18 | margin: 0; 19 | box-sizing: border-box; 20 | width: 100%; 21 | height: 100%; 22 | cursor: pointer; 23 | } 24 | 25 | .switch__label { 26 | position: relative; 27 | z-index: 0; 28 | overflow: hidden; 29 | flex: 1 1 auto; 30 | padding: 0.4em 1em; 31 | border: 1px solid var(--color-text); 32 | border-radius: 6px; 33 | } 34 | 35 | .switch__label::before { 36 | content: ""; 37 | opacity: 0; 38 | position: absolute; 39 | inset: -1px; 40 | background-color: currentColor; 41 | transition: opacity 0.2s; 42 | } 43 | 44 | .switch__input:checked + .switch__label { 45 | color: var(--color-background); 46 | background-color: var(--color-text); 47 | } 48 | 49 | .switch__item:first-child .switch__label { 50 | border-top-right-radius: 0; 51 | border-bottom-right-radius: 0; 52 | } 53 | 54 | .switch__item:last-child .switch__label { 55 | border-top-left-radius: 0; 56 | border-bottom-left-radius: 0; 57 | } 58 | 59 | .switch__item:not(:first-child, :last-child) .switch__label { 60 | border-radius: 0; 61 | } 62 | 63 | .switch__item:not(:first-child) { 64 | margin-left: -1px; 65 | } 66 | 67 | .switch__item:hover .switch__label::before { 68 | opacity: 69 | calc( 70 | var(--is-light-theme-on) * 0.06 + 71 | var(--is-dark-theme-on) * 0.12 72 | ); 73 | } 74 | 75 | .switch:focus-within { 76 | outline: auto 2px Highlight; 77 | outline: auto 2px -webkit-focus-ring-color; 78 | outline-offset: 2px; 79 | border-radius: 6px; 80 | } 81 | -------------------------------------------------------------------------------- /src/scripts/modules/header-quick-search-presenter.js: -------------------------------------------------------------------------------- 1 | import { escape } from 'html-escaper' 2 | import debounce from '../libs/debounce.js' 3 | import searchClient from '../core/search-api-client.js' 4 | import { MIN_SEARCH_SYMBOLS, SEARCHABLE_SHORT_WORDS, processHits } from '../core/search-commons.js' 5 | import headerComponent from './header.js' 6 | import logo from '../modules/logo.js' 7 | 8 | async function getQuickSearchInstance() { 9 | const moduleExports = await import('./quick-search.js') 10 | return moduleExports.default 11 | } 12 | 13 | async function init() { 14 | const isSearchPage = window.location.pathname.indexOf('/search/') > -1 15 | 16 | if (isSearchPage) { 17 | return 18 | } 19 | 20 | const quickSearch = await getQuickSearchInstance() 21 | 22 | if (!quickSearch) { 23 | return 24 | } 25 | 26 | const DEBOUNCE_TIME = 150 27 | 28 | function onSearch(event) { 29 | const queryText = event.detail 30 | 31 | if (!(queryText.length >= MIN_SEARCH_SYMBOLS || SEARCHABLE_SHORT_WORDS.has(queryText))) { 32 | quickSearch.clearOutput() 33 | return 34 | } 35 | 36 | quickSearch.openSuggestion() 37 | logo.startAnimation() 38 | 39 | searchClient 40 | .search(queryText) 41 | .then((searchObject) => { 42 | const processedHits = processHits(searchObject).map((hitObject) => ({ 43 | ...hitObject, 44 | title: escape(hitObject.title), 45 | })) 46 | quickSearch.renderResults(processedHits) 47 | }) 48 | .catch(console.error) 49 | .finally(() => { 50 | logo.endAnimation() 51 | }) 52 | } 53 | 54 | headerComponent.on('menu.close', () => { 55 | quickSearch.exit() 56 | }) 57 | 58 | quickSearch.on('search', debounce(onSearch, DEBOUNCE_TIME)) 59 | } 60 | 61 | init() 62 | -------------------------------------------------------------------------------- /src/styles/blocks/filter-panel.css: -------------------------------------------------------------------------------- 1 | .filter-panel { 2 | --lightness: calc(100% - var(--is-dark-theme-on) * 80%); 3 | position: relative; 4 | overflow: auto; 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | 9 | .filter-panel__inner { 10 | margin: 0; 11 | padding: var(--offset); 12 | border: 0; 13 | min-height: 0; 14 | background-color: var(--color-background); 15 | } 16 | 17 | .filter-panel__button { 18 | min-height: 108px; 19 | } 20 | 21 | .filter-panel--open { 22 | --is-filter-open: 1; 23 | } 24 | 25 | @media not all and (width >= 768px) { 26 | .filter-panel { 27 | --is-filter-open: 0; 28 | position: fixed; 29 | inset: 0; 30 | } 31 | 32 | .filter-panel::before { 33 | content: ""; 34 | opacity: var(--is-filter-open); 35 | position: absolute; 36 | inset: 0; 37 | background-color: hsl(var(--color-fade) / 0.45); 38 | backdrop-filter: grayscale(1); 39 | transition: 0.5s; 40 | pointer-events: none; 41 | } 42 | 43 | .filter-panel__inner { 44 | opacity: var(--is-filter-open); 45 | transform: translateY(calc((var(--is-filter-open) - 1) * 100%)); 46 | flex: 0 1 auto; 47 | overflow: auto; 48 | text-align: start; 49 | min-height: 0; 50 | max-height: calc(100vh - 108px); 51 | transition: 52 | transform 0.5s cubic-bezier(0.65, 0.05, 0.36, 1), 53 | opacity 0.5s; 54 | background-color: hsl(0 0% var(--lightness)); 55 | } 56 | 57 | .filter-panel__button { 58 | flex: 1 1 auto; 59 | } 60 | 61 | .filter-panel--open { 62 | --is-filter-open: 1; 63 | } 64 | 65 | .filter-panel:not(.filter-panel--open) { 66 | visibility: hidden; 67 | transition-delay: 0.5s; 68 | } 69 | } 70 | 71 | @media (width >= 768px) { 72 | .filter-panel__button { 73 | display: none; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/scripts/libs/__tests__/last-update-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import { init } from '../../modules/last-update' 6 | 7 | const createPastDate = (passedSeconds) => { 8 | // Создаёт дату на момент «N-секунд назад» и возвращает её для последующих проверок 9 | const date = new Date() 10 | 11 | if (passedSeconds >= 3600) { 12 | date.setHours(date.getHours() - passedSeconds / 3600) 13 | } else if (passedSeconds >= 60) { 14 | date.setMinutes(date.getMinutes() - passedSeconds / 60) 15 | } else { 16 | date.setSeconds(date.getSeconds() - passedSeconds) 17 | } 18 | 19 | const time = document.createElement('time') 20 | time.setAttribute('data-relative-time', null) 21 | time.setAttribute('datetime', date.toISOString()) 22 | 23 | if (document.body.hasChildNodes()) { 24 | document.body.firstChild.remove() 25 | } 26 | 27 | document.body.append(time) 28 | 29 | return time 30 | } 31 | 32 | describe('last-update', () => { 33 | it('должен создавать языковую запись времени в секундах', () => { 34 | const date = createPastDate(30) 35 | init() 36 | expect(date.textContent).toMatch('30 секунд назад') 37 | }) 38 | 39 | it('должен создавать языковую запись времени в минутах', () => { 40 | const date = createPastDate(600) 41 | init() 42 | expect(date.textContent).toMatch('10 минут назад') 43 | }) 44 | 45 | it('должен создавать языковую запись времени в часах и округлять значение в большую сторону', () => { 46 | const date = createPastDate(7000) 47 | init() 48 | expect(date.textContent).toMatch('2 часа назад') 49 | }) 50 | 51 | it('должен проигнорировать создание языковой записи времени', () => { 52 | const date = createPastDate(86400) 53 | init() 54 | expect(date.textContent).toMatch('') 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/styles/blocks/intro.css: -------------------------------------------------------------------------------- 1 | .intro { 2 | --gutter: 10px; 3 | padding-top: 40px; 4 | padding-bottom: 40px; 5 | padding-bottom: clamp(40px, 4.8%, 80px); 6 | display: grid; 7 | grid-template-rows: repeat(2, auto); 8 | gap: 20px; 9 | } 10 | 11 | @media (width >= 768px) { 12 | .intro { 13 | --gutter: 20px; 14 | grid-template-rows: unset; 15 | grid-template-columns: repeat(2, 1fr); 16 | } 17 | } 18 | 19 | .intro__description { 20 | display: block; 21 | margin: 0; 22 | } 23 | 24 | .intro__link { 25 | display: flex; 26 | align-items: center; 27 | gap: 0.2em; 28 | } 29 | 30 | .intro__icon { 31 | width: 0.968em; 32 | height: 0.968em; 33 | } 34 | 35 | .intro__icon--invertible { 36 | filter: invert(var(--is-dark-theme-on)); 37 | } 38 | 39 | .intro__pitch { 40 | display: grid; 41 | grid-template-rows: calc(1.5 * var(--font-size-l)) auto min-content; 42 | gap: calc(2 * var(--gutter)); 43 | padding: var(--gutter); 44 | background-color: hsl(var(--color-fade)); 45 | border-radius: 6px; 46 | } 47 | 48 | .intro__pitch--partner { 49 | border: 1px solid hsl(var(--color-fade)); 50 | border-radius: 6px; 51 | background-color: transparent; 52 | } 53 | 54 | .intro__moto { 55 | align-self: self-start; 56 | font-family: var(--font-family); 57 | font-size: var(--font-size-l); 58 | font-weight: 300; 59 | line-height: 1; 60 | margin: 0; 61 | } 62 | 63 | .intro__accent { 64 | border-radius: 1em; 65 | padding: 0 0.3em; 66 | background-color: hsl(var(--color-fade)); 67 | white-space: nowrap; 68 | } 69 | 70 | .intro__footer { 71 | display: flex; 72 | flex-wrap: wrap; 73 | gap: var(--font-size-m); 74 | } 75 | 76 | .intro__logo { 77 | height: calc(2 * var(--gutter)); 78 | } 79 | 80 | .intro__logo--invertible { 81 | filter: invert(var(--is-dark-theme-on)); 82 | } 83 | -------------------------------------------------------------------------------- /src/styles/blocks/top-banner.css: -------------------------------------------------------------------------------- 1 | .top-banner { 2 | position: relative; 3 | justify-content: space-between; 4 | align-items: center; 5 | padding-inline-start: 150px; 6 | color: #fff; 7 | background-color: #000; 8 | z-index: 999; 9 | } 10 | 11 | .top-banner[hidden] { 12 | display: none; 13 | } 14 | 15 | .top-banner::before { 16 | content: ""; 17 | position: absolute; 18 | inset-inline-start: 15px; 19 | inset-block-start: calc(50% - 15px); 20 | inline-size: 120px; 21 | block-size: 30px; 22 | background-image: url("../../images/top-banner/bat-eyes.svg"); 23 | background-repeat: no-repeat; 24 | background-position: center; 25 | background-size: contain; 26 | } 27 | 28 | .top-banner::after { 29 | content: ""; 30 | position: absolute; 31 | inset-inline-start: 45px; 32 | inset-block-end: -12px; 33 | inline-size: 60px; 34 | block-size: 13px; 35 | background-image: url("../../images/top-banner/bat-teeth.svg"); 36 | background-repeat: no-repeat; 37 | background-position: center; 38 | background-size: contain; 39 | } 40 | 41 | .top-banner__content { 42 | margin: 0; 43 | } 44 | 45 | @media (width <= 1366px) { 46 | .top-banner { 47 | padding-inline-start: 110px; 48 | } 49 | 50 | .top-banner::before { 51 | inset-block-start: calc(50% - 10px); 52 | inline-size: 80px; 53 | block-size: 20px; 54 | } 55 | 56 | .top-banner::after { 57 | inset-inline-start: 33px; 58 | inset-block-end: -8px; 59 | inline-size: 45px; 60 | block-size: 9px; 61 | } 62 | } 63 | 64 | @media (width <= 768px) { 65 | .top-banner { 66 | margin-block-end: 0; 67 | padding-inline-start: 15px; 68 | font-size: 14px; 69 | } 70 | 71 | .top-banner::before, .top-banner::after { 72 | display: none; 73 | } 74 | 75 | .top-banner__button { 76 | padding-block: 0.3em; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/views/article-index-json.11tydata.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | pagination: { 3 | data: 'collections.articleIndexes', 4 | size: 1, 5 | alias: 'articleIndex', 6 | }, 7 | 8 | permalink: '/{{ articleIndex.fileSlug }}/index.json', 9 | 10 | eleventyComputed: { 11 | category: function (data) { 12 | const { articleIndex } = data 13 | return articleIndex.fileSlug 14 | }, 15 | 16 | categoryArticles: function (data) { 17 | const { collections, category } = data 18 | return collections[category] 19 | }, 20 | 21 | json: function (data) { 22 | const { category, categoryArticles } = data 23 | const json = categoryArticles 24 | ?.filter?.(async (article) => { 25 | const cache = await article.template._frontMatterDataCache 26 | return cache['tags'].includes('doka') 27 | }) 28 | ?.reduce?.(async (map, article) => { 29 | const data = {} 30 | const cache = await article.template._frontMatterDataCache 31 | const content = await article.template.inputContent 32 | data['path'] = `/${category}/${article.fileSlug}/` 33 | data['related'] = cache['related'] 34 | const summary = content.replace('\n\n', '\n').split('---')[2].split('\n') 35 | let headerIndices = [] 36 | summary.forEach((string, index) => { 37 | if (string.startsWith('## ')) { 38 | headerIndices.push(index) 39 | } 40 | }) 41 | map[cache['title']] = { 42 | ...data, 43 | summary: summary 44 | .slice(0, headerIndices[1]) 45 | .filter((content) => content !== '') 46 | .filter((content) => content !== '## Кратко'), 47 | } 48 | return map 49 | }, {}) 50 | return json 51 | }, 52 | }, 53 | } 54 | -------------------------------------------------------------------------------- /src/styles/blocks/search-hit.css: -------------------------------------------------------------------------------- 1 | .search-hit { 2 | position: relative; 3 | } 4 | 5 | .search-hit__title { 6 | margin-top: 0; 7 | margin-bottom: 10px; 8 | font-size: var(--font-size-l); 9 | line-height: 1.15; 10 | letter-spacing: var(--letter-spacing); 11 | font-family: var(--font-family); 12 | font-weight: normal; 13 | } 14 | 15 | .search-hit__link { 16 | --stroke-color: var(--accent-color); 17 | color: inherit; 18 | text-decoration: none; 19 | 20 | /* в Chrome у иконки линия подчёркивания рисуется ниже основной, поэтому text-decoration заменён на градиент */ 21 | background-image: linear-gradient(90deg, var(--accent-color), var(--accent-color)); 22 | background-repeat: repeat-x; 23 | background-size: 100% 2px; 24 | background-position: 0 100%; 25 | } 26 | 27 | .search-hit__link::before { 28 | content: ""; 29 | position: absolute; 30 | top: 0; 31 | bottom: 0; 32 | left: 0; 33 | right: 0; 34 | } 35 | 36 | .search-hit__link:hover { 37 | color: var(--accent-color); 38 | } 39 | 40 | .search-hit__link-code { 41 | font-size: var(--font-size-l); 42 | line-height: var(--font-line-height-l); 43 | letter-spacing: var(--letter-spacing); 44 | font-family: var(--font-family); 45 | } 46 | 47 | .search-hit__text-code { 48 | font-size: var(--font-size-m); 49 | line-height: var(--font-line-height-m); 50 | letter-spacing: var(--letter-spacing); 51 | font-family: var(--font-family); 52 | } 53 | 54 | .search-hit__edit { 55 | font-family: var(--font-family); 56 | font-size: var(--font-size-l); 57 | } 58 | 59 | .search-hit__edit::before { 60 | content: "✎"; 61 | } 62 | 63 | .search-hit__summary { 64 | overflow-wrap: anywhere; 65 | } 66 | 67 | .search-hit__marked { 68 | background-color: hsl(44deg 100% 59%); 69 | } 70 | 71 | @media (width >= 1024px) { 72 | .search-hit__title { 73 | margin-bottom: 14px; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.github/scripts/update-release.sh: -------------------------------------------------------------------------------- 1 | MONTH_TO_ENG() { 2 | case "$1" in 3 | 01) echo "December" ;; 4 | 02) echo "January" ;; 5 | 03) echo "February" ;; 6 | 04) echo "March" ;; 7 | 05) echo "April" ;; 8 | 06) echo "May" ;; 9 | 07) echo "June" ;; 10 | 08) echo "July" ;; 11 | 09) echo "August" ;; 12 | 10) echo "September" ;; 13 | 11) echo "October" ;; 14 | 12) echo "November" ;; 15 | esac 16 | } 17 | 18 | MONTH_TO_RUS() { 19 | case "$1" in 20 | 01) echo "декабрь" ;; 21 | 02) echo "январь" ;; 22 | 03) echo "февраль" ;; 23 | 04) echo "март" ;; 24 | 05) echo "апрель" ;; 25 | 06) echo "май" ;; 26 | 07) echo "июнь" ;; 27 | 08) echo "июль" ;; 28 | 09) echo "август" ;; 29 | 10) echo "сентябрь" ;; 30 | 11) echo "октябрь" ;; 31 | 12) echo "ноябрь" ;; 32 | esac 33 | } 34 | 35 | CURRENT_MONTH=$(date -u +"%m") 36 | CURRENT_YEAR=$(date -u +"%Y") 37 | SHORT_YEAR=$(date -u +"%y") 38 | if [[ "$CURRENT_MONTH" == "01" ]]; then 39 | CURRENT_YEAR="$(($CURRENT_YEAR - 1))" 40 | SHORT_YEAR="$(($SHORT_YEAR - 1))" 41 | fi 42 | RELEASE_MONTH="$((${CURRENT_MONTH#0} - 1))" 43 | 44 | TAG="v.$RELEASE_MONTH.$SHORT_YEAR" 45 | TITLE="$(MONTH_TO_ENG $CURRENT_MONTH) $CURRENT_YEAR ($TAG)" 46 | 47 | SUBTITLE_OLD_1="## What's Changed" 48 | SUBTITLE_OLD_2="## New Contributors" 49 | SUBTITLE_OLD_3="Full Changelog" 50 | 51 | SUBTITLE_NEW_1="## Технический ченджлог ($(MONTH_TO_RUS $CURRENT_MONTH) $CURRENT_YEAR)" 52 | SUBTITLE_NEW_2="## Новые контрибьюторы" 53 | SUBTITLE_NEW_3="Весь ченджлог" 54 | 55 | gh repo set-default doka-guide/platform 56 | gh release create "$TAG" --title="$TITLE" --generate-notes 57 | gh release view --repo=github.com/doka-guide/platform >> auto-notes.md 58 | sed -E 's/\* /- /g' auto-notes.md | sed -E 's/'"$SUBTITLE_OLD_1"'/'"$SUBTITLE_NEW_1"'/' | sed -E 's/'"$SUBTITLE_OLD_2"'/'"$SUBTITLE_NEW_2"'/' | sed -E 's/'"$SUBTITLE_OLD_3"'/'"$SUBTITLE_NEW_3"'/' > notes.md 59 | gh release edit "$TAG" --notes-file notes.md 60 | -------------------------------------------------------------------------------- /src/includes/blocks/person.njk: -------------------------------------------------------------------------------- 1 | {% from "blocks/person-avatar.njk" import personAvatar %} 2 | 3 | {% macro person(data) %} 4 |
5 | {{ personAvatar( 6 | class = 'person__avatar', 7 | photoURL = data.photoURL, 8 | name = data.name, 9 | category = data.mostContributedCategory 10 | ) }} 11 | 12 |
13 | 14 | 15 | 16 | {% if data.totalArticles > 0 %} 17 | {{ data.totalArticles }} 18 | {{ data.totalArticles | declension('статья', 'статьи', 'статей') }} 19 | {% elif data.totalPractices > 0 %} 20 | {{ data.totalPractices }} 21 | {{ data.totalPractices | declension('совет', 'совета', 'советов') }} 22 | {% elif data.totalAnswers > 0 %} 23 | {{ data.totalAnswers }} 24 | {{ data.totalAnswers | declension('ответ', 'ответа', 'ответов') }} 25 | {% endif %} 26 | 27 | 28 | 29 | {% if data.stat | length %} 30 | {% for category, count in data.stat %} 31 | 32 | {% endfor %} 33 | {% elif data.practices | length %} 34 | {% for category, count in data.practices %} 35 | 36 | {% endfor %} 37 | {% elif data.answers | length %} 38 | {% for category, count in data.answers %} 39 | 40 | {% endfor %} 41 | {% endif %} 42 | 43 |
44 |
45 | {% endmacro %} 46 | -------------------------------------------------------------------------------- /src/views/sc-index.11tydata.js: -------------------------------------------------------------------------------- 1 | const { titleFormatter } = require('../libs/title-formatter/title-formatter') 2 | 3 | const letterOnlyRegExp = /[a-zа-яё]/i 4 | 5 | module.exports = { 6 | pagination: { 7 | data: 'collections.articleIndexes', 8 | size: 1, 9 | alias: 'articleIndex', 10 | }, 11 | 12 | permalink: '/{{ articleIndex.fileSlug }}/index.sc.html', 13 | 14 | eleventyComputed: { 15 | category: function (data) { 16 | const { articleIndex } = data 17 | return articleIndex.fileSlug 18 | }, 19 | 20 | categoryName: function (data) { 21 | const { articleIndex } = data 22 | return articleIndex.data.name 23 | }, 24 | 25 | documentTitle: function (data) { 26 | return titleFormatter([data.categoryName, 'Дока']) 27 | }, 28 | 29 | categoryLink: function (data) { 30 | const { category } = data 31 | return `/${category}/` 32 | }, 33 | 34 | groups: function (data) { 35 | const { articleIndex } = data 36 | return articleIndex.data.groups 37 | }, 38 | 39 | categoryArticles: function (data) { 40 | const { collections, category } = data 41 | return collections[category] 42 | }, 43 | 44 | categoryArticlesByAlphabet: function (data) { 45 | const { categoryArticles } = data 46 | return categoryArticles?.reduce?.((map, article) => { 47 | const { title } = article.data 48 | 49 | let firstLetter 50 | for (const letter of title) { 51 | if (letterOnlyRegExp.test(letter)) { 52 | firstLetter = letter.toLowerCase() 53 | break 54 | } 55 | } 56 | 57 | map[firstLetter] = map[firstLetter] || [] 58 | map[firstLetter].push(article.fileSlug) 59 | return map 60 | }, {}) 61 | }, 62 | 63 | firstLettersOfArticles: function (data) { 64 | const { categoryArticlesByAlphabet = {} } = data 65 | return Object.keys(categoryArticlesByAlphabet).sort() 66 | }, 67 | }, 68 | } 69 | -------------------------------------------------------------------------------- /src/transforms/image-place-transform.js: -------------------------------------------------------------------------------- 1 | const { Node } = require('linkedom') 2 | 3 | // Помещаем изображения с подписями внутрь figure 4 | /** 5 | * 6 | * @param {Window} window 7 | */ 8 | module.exports = function (window) { 9 | const articleContent = window.document.querySelector('.article__content-inner') 10 | 11 | if (!articleContent) { 12 | return 13 | } 14 | 15 | Array.from(articleContent.querySelectorAll('img, picture')) 16 | .filter((element) => !element.matches('figure img, picture img')) 17 | .forEach((element) => { 18 | // обычно все markdown-парсеры используют тег 'br' для переноса 19 | const brElement = element.nextElementSibling 20 | const hasCaption = brElement?.tagName.toLowerCase() === 'br' 21 | 22 | if (!hasCaption) { 23 | return 24 | } 25 | 26 | const fragments = [] 27 | 28 | let sibling = brElement.nextSibling 29 | while (sibling) { 30 | fragments.push(sibling) 31 | sibling = sibling.nextSibling 32 | } 33 | 34 | const figure = window.document.createElement('figure') 35 | brElement.remove() 36 | figure.innerHTML = element.outerHTML 37 | 38 | const figCaption = window.document.createElement('figcaption') 39 | figCaption.innerHTML = fragments 40 | .map((fragment) => (fragment.nodeType === Node.TEXT_NODE ? fragment.textContent : fragment.outerHTML)) 41 | .join('') 42 | 43 | figure.appendChild(figCaption) 44 | element.replaceWith(figure) 45 | }) 46 | 47 | articleContent.querySelectorAll('figure').forEach((figureElement) => { 48 | figureElement.classList.add('figure') 49 | figureElement.querySelector('figcaption')?.classList.add('figure__caption') 50 | figureElement.firstElementChild?.classList.add('figure__content') 51 | }) 52 | 53 | // достаём изображения из параграфов 54 | articleContent.querySelectorAll('p > figure, p > picture, p > img').forEach((element) => { 55 | element.parentElement.replaceWith(element) 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/product-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Product Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | env: 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | PATH_TO_CONTENT: ./content 14 | steps: 15 | - name: Загрузка платформы 16 | uses: actions/checkout@v4 17 | - name: Загрузка контента 18 | uses: actions/checkout@v4 19 | with: 20 | repository: doka-guide/content 21 | path: content 22 | - name: Загрузка кеша 23 | uses: actions/checkout@v4 24 | with: 25 | repository: doka-guide/cache 26 | path: cache 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version: 20 30 | - name: Кэширование модулей 31 | uses: actions/cache@v3 32 | env: 33 | cache-name: cache-node-modules 34 | with: 35 | path: ~/.npm 36 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 37 | restore-keys: | 38 | ${{ runner.os }}-build-${{ env.cache-name }}- 39 | ${{ runner.os }}-build- 40 | ${{ runner.os }}- 41 | - name: Установка ключа для пользователя 42 | run: | 43 | set -eu 44 | mkdir "$HOME/.ssh" 45 | echo "${{ secrets.DEPLOY_KEY }}" > "$HOME/.ssh/doka_deploy" 46 | chmod 600 "$HOME/.ssh/doka_deploy" 47 | echo "${{ secrets.DEPLOY_CONFIG }}" > "$HOME/.ssh/config" 48 | chmod 600 "$HOME/.ssh/config" 49 | ssh -o StrictHostKeyChecking=no deploy@dev.doka.guide 50 | - name: Установка модулей 51 | run: npm ci 52 | - name: Копирование кеша 53 | run: cp ./cache/issues.json ./.issues.json 54 | - name: Сборка сайта 55 | run: | 56 | cp .env.example .env 57 | npm run build 58 | - name: Публикация сайта 59 | run: | 60 | cd dist 61 | rsync --exclude 'api.json' --exclude 'mail' --archive --progress --compress --delete . dev.doka.guide:/web/sites/doka.guide/www/ 62 | -------------------------------------------------------------------------------- /src/scripts/modules/logo.js: -------------------------------------------------------------------------------- 1 | class Logo { 2 | static get constants() { 3 | return { 4 | animationStateClass: 'logo__image--animation', 5 | animationName: 'logoAnimation', 6 | } 7 | } 8 | 9 | constructor() { 10 | const rootElement = document.querySelector('.logo') 11 | 12 | this.refs = { 13 | rootElement, 14 | image: rootElement.querySelector('.logo__image'), 15 | symbols: rootElement.querySelector('.logo__symbols'), 16 | } 17 | 18 | this._isAnimation = false 19 | } 20 | 21 | setFocusOnElement() { 22 | this.refs.symbols.innerHTML = 23 | 'U><U' 24 | } 25 | 26 | unsetFocusOnElement() { 27 | this.refs.symbols.innerHTML = 28 | 'UU' 29 | } 30 | 31 | startAnimation() { 32 | if (this._isAnimation) { 33 | return 34 | } 35 | this._isAnimation = true 36 | this.refs.image.classList.add(Logo.constants.animationStateClass) 37 | } 38 | 39 | endAnimation() { 40 | const isSearchPage = window.location.pathname.indexOf('/search/') > -1 41 | const logoImage = document.querySelector('.logo__image') 42 | let firstResultColor 43 | 44 | if (isSearchPage) { 45 | firstResultColor = document?.querySelector('.search-hit')?.getAttribute('style') 46 | } else { 47 | firstResultColor = document?.querySelector('.suggestion-list__item')?.getAttribute('style') 48 | } 49 | 50 | if (firstResultColor) { 51 | logoImage.setAttribute('style', `${firstResultColor}`) 52 | } else { 53 | logoImage.removeAttribute('style') 54 | } 55 | 56 | this.refs.image.addEventListener( 57 | 'animationiteration', 58 | (event) => { 59 | if (event.animationName !== Logo.constants.animationName) { 60 | return 61 | } 62 | this._isAnimation = false 63 | this.refs.image.classList.remove(Logo.constants.animationStateClass) 64 | }, 65 | { once: true }, 66 | ) 67 | } 68 | } 69 | 70 | export default new Logo() 71 | -------------------------------------------------------------------------------- /src/scripts/modules/form-cache.js: -------------------------------------------------------------------------------- 1 | let db 2 | 3 | function getObjectStore(dbStoreName, mode) { 4 | if (!db) { 5 | return null 6 | } 7 | const tx = db.transaction(dbStoreName, mode) 8 | return tx.objectStore(dbStoreName) 9 | } 10 | 11 | export function closeAndDeleteDb(dbStoreName) { 12 | db.close() 13 | 14 | const indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB 15 | indexedDB.deleteDatabase(dbStoreName) 16 | } 17 | 18 | export function setupDb(dbStoreName, dbVersion, fields) { 19 | const indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB 20 | const request = indexedDB.open(dbStoreName, dbVersion) 21 | 22 | request.onerror = (event) => { 23 | console.error(event.target.errorCode) 24 | } 25 | 26 | request.onsuccess = (event) => { 27 | db = event.target.result 28 | } 29 | 30 | request.onupgradeneeded = (event) => { 31 | const thisDb = event.target.result 32 | 33 | thisDb.onerror = (event) => { 34 | console.error(event) 35 | } 36 | 37 | if (!thisDb.objectStoreNames.contains(dbStoreName)) { 38 | const objectStore = thisDb.createObjectStore(dbStoreName, { keyPath: 'id', autoIncrement: true }) 39 | 40 | for (let i = 0; i < fields.length; i++) { 41 | objectStore.createIndex(fields[i], fields[i], { unique: false }) 42 | } 43 | } 44 | } 45 | } 46 | 47 | export function saveToDb(dbStoreName, newRecord) { 48 | const store = getObjectStore(dbStoreName, 'readwrite') 49 | if (!store) { 50 | return 51 | } 52 | const req = store.add(newRecord) 53 | req.onerror = () => { 54 | console.error(this.error) 55 | } 56 | } 57 | 58 | export function sendFromDb(dbStoreName, sendData) { 59 | const store = getObjectStore(dbStoreName, 'readwrite') 60 | if (!store) { 61 | return 62 | } 63 | const req = store.getAll() 64 | req.onerror = () => { 65 | console.error(this.error) 66 | } 67 | req.onsuccess = async (event) => { 68 | for (let i = 0; i < event.target.result.length; i++) { 69 | const formData = event.target.result[i] 70 | await sendData(formData) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/styles/blocks/index-section.css: -------------------------------------------------------------------------------- 1 | .index-section { 2 | --offset: 10px; 3 | --gutter: 40px; 4 | --column-breakpoint: 15em; 5 | padding: var(--offset); 6 | line-height: 1.25; 7 | will-change: transform, opacity; 8 | animation: showIndexSection 0.4s forwards; 9 | } 10 | 11 | .index-section:not([hidden]) { 12 | display: grid; 13 | grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--column-breakpoint)), 1fr)); 14 | column-gap: var(--offset); 15 | align-items: start; 16 | } 17 | 18 | @keyframes showIndexSection { 19 | from { 20 | opacity: 0; 21 | transform: translateY(-1.25em); 22 | } 23 | } 24 | 25 | .index-section__item { 26 | scroll-margin-block: calc(var(--header-height) * 1px + 1rem); 27 | position: relative; 28 | padding-bottom: var(--gutter); 29 | display: flow-root; 30 | page-break-inside: avoid; 31 | break-inside: avoid; 32 | } 33 | 34 | @media (width >= 1024px) { 35 | .index-section__item { 36 | scroll-margin-block: calc(var(--header-height) * 1px + 2rem); 37 | } 38 | } 39 | 40 | .index-section__item::before { 41 | content: ""; 42 | opacity: 0; 43 | position: absolute; 44 | z-index: -1; 45 | inset: calc(-1 * var(--offset) / 2); 46 | bottom: calc(var(--gutter) - var(--offset) / 2); 47 | background-color: var(--accent-color); 48 | } 49 | 50 | .index-section__item:target::before { 51 | animation-name: targetArticlesGroup; 52 | animation-iteration-count: 3; 53 | animation-fill-mode: both; 54 | animation-duration: 1s; 55 | } 56 | 57 | @keyframes targetArticlesGroup { 58 | 0%, 59 | 100% { 60 | opacity: 0; 61 | } 62 | 63 | 50% { 64 | opacity: calc(var(--is-light-theme-on) * 0.2 + var(--is-dark-theme-on) * 0.5); 65 | } 66 | } 67 | 68 | @media (prefers-reduced-motion: reduce) { 69 | .index-section__item:target::before { 70 | animation-iteration-count: 1; 71 | animation-duration: 4s; 72 | } 73 | } 74 | 75 | @media (width >= 768px) { 76 | .index-section { 77 | --offset: 20px; 78 | --gutter: 22px; 79 | } 80 | } 81 | 82 | @media (width >= 1024px) { 83 | .index-section { 84 | --gutter: 60px; 85 | } 86 | } 87 | 88 | @media (width >= 1680px) { 89 | .index-section { 90 | --gutter: 70px; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/scripts/modules/people.js: -------------------------------------------------------------------------------- 1 | function init() { 2 | const rootElement = document.querySelector('.people-page') 3 | 4 | if (!rootElement) { 5 | return 6 | } 7 | 8 | const filter = rootElement.querySelector('.people-page__filter') 9 | const grid = rootElement.querySelector('.person-grid') 10 | const [allControl, ...restControls] = Array.from(rootElement.querySelectorAll('.tag-filter__control')) 11 | 12 | function getFiltersValues() { 13 | return restControls.filter((control) => control.checked).map((control) => control.value) 14 | } 15 | 16 | function applyFilters() { 17 | grid.dataset.filters = getFiltersValues().join(',') 18 | } 19 | 20 | function saveToURL() { 21 | const entries = [...new FormData(filter).entries()].filter(([, value]) => !!value) 22 | 23 | const serializedState = entries.length !== 0 ? '?' + new URLSearchParams(entries) : window.location.pathname 24 | 25 | history.pushState(null, null, serializedState) 26 | } 27 | 28 | function initUIFromURL() { 29 | const params = new URLSearchParams(window.location.search) 30 | const entriesSet = new Set([...params.values()]) 31 | 32 | for (const control of restControls) { 33 | control.checked = entriesSet.has(control.value) 34 | } 35 | 36 | allControl.checked = entriesSet.size === 0 37 | } 38 | 39 | function onFilterChange(event) { 40 | const { value, checked } = event.target 41 | 42 | switch (true) { 43 | case !value && checked: { 44 | for (const control of restControls) { 45 | control.checked = false 46 | } 47 | break 48 | } 49 | 50 | case !value && !checked: { 51 | allControl.checked = true 52 | break 53 | } 54 | 55 | case value && checked: { 56 | allControl.checked = false 57 | break 58 | } 59 | 60 | case value && !checked: { 61 | const filterValues = getFiltersValues() 62 | if (filterValues.length === 0) { 63 | allControl.checked = true 64 | } 65 | break 66 | } 67 | } 68 | 69 | applyFilters() 70 | saveToURL() 71 | } 72 | 73 | filter.addEventListener('change', onFilterChange) 74 | initUIFromURL() 75 | applyFilters() 76 | } 77 | 78 | init() 79 | -------------------------------------------------------------------------------- /docs/how-to-run.md: -------------------------------------------------------------------------------- 1 | # Как запустить Доку локально 2 | 3 | Для работы с платформой вам потребуется [Node.js](https://nodejs.org/en/) и npm. Мы используем стабильную LTS-версию Node.js и версию npm, которая идёт в комплекте. Если у вас установлена другая версия Node.js, вы можете использовать [nvm](https://github.com/nvm-sh/nvm) для переключения на нужную. 4 | 5 | ## Минимальный запуск 6 | 7 | Чтобы запустить Доку локально, нужно: 8 | 9 | 1. Скачать репозиторий. 10 | 1. Установить зависимости командой `npm i`. 11 | 1. Сделать копию файла `.env.example` и назвать его `.env`. Задать в нём нужные переменные окружения. 12 | 1. Запустить локальный веб-сервер командой `npm start`. 13 | 14 | ## Запуск с сервис воркером 15 | 16 | Чтобы запустить Доку с сервис воркером, нужно: 17 | 18 | 1. Сделать всё описанное в предыдущем пункте. 19 | 2. Добавить переменную `DOKA_MODE` в localStorage. 20 | 3. Установить значение переменной `DOKA_MODE` в `DEBUG`. 21 | 22 | ## Запуск с реальным контентом 23 | 24 | 1. Скачать репозитории с контентом и платформой в одну папку. 25 | 1. Установить зависимости командой `npm i`. 26 | 1. Сделать копию файла `.env.example` и назвать его `.env`. Задать в нём нужные переменные окружения: 27 | - `BASE_URL` - базовый адрес для сайта; 28 | - `SECTIONS` - список разделов сайта; 29 | - `PATH_TO_CONTENT` - путь до репозитория с контентом; 30 | - `CONTENT_REP_FOLDERS` - папки с содержимым разделов и служебной информацией для сборки; 31 | - `DOKA_ORG` - путь до организации на GitHub; 32 | - `PLATFORM_REP_GITHUB_URL` - путь до репозитория с платформой на GitHub; 33 | - `CONTENT_REP_GITHUB_URL` - путь до репозитория с контентом на GitHub; 34 | - `CONTENT_REP_GITHUB` - ссылка до репозитория с контентом на GitHub для работы с Git; 35 | - `SERVER_PATH` - абсолютный путь до папки на сервере с текущей сборкой; 36 | - `GITHUB_TOKEN` - токен для работы с GraphQL GitHub (персональный токен можно сгенерировать, как описано в [инструкции](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token)). 37 | 2. Запустить локальный веб-сервер командой `npm start`. 38 | 39 | Если оставить поле `GITHUB_TOKEN` пустым, на страницах участников не будет отображена информация об активности на GitHub в репозитории с контентом. 40 | -------------------------------------------------------------------------------- /src/styles/blocks/footer.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | border-block-start: 1px solid var(--color-border); 3 | padding: 20px 10px; 4 | display: flex; 5 | gap: 30px; 6 | align-items: flex-start; 7 | justify-content: space-between; 8 | flex-wrap: wrap; 9 | } 10 | 11 | .footer__theme-toggle { 12 | padding: 0; 13 | margin: 0; 14 | display: flex; 15 | align-items: baseline; 16 | gap: 0.5em; 17 | font-size: var(--font-size-s); 18 | line-height: var(--font-line-height-s); 19 | border: 0; 20 | } 21 | 22 | .footer__lists { 23 | display: flex; 24 | flex-wrap: wrap; 25 | gap: 30px; 26 | } 27 | 28 | .footer__list { 29 | display: flex; 30 | flex-direction: column; 31 | flex-wrap: wrap; 32 | gap: 5px; 33 | font-family: var(--font-family); 34 | font-size: var(--font-size-s); 35 | line-height: var(--font-line-height-s); 36 | letter-spacing: var(--letter-spacing); 37 | } 38 | 39 | .footer__list_social { 40 | flex-direction: row; 41 | gap: 5px; 42 | } 43 | 44 | .footer-list__icon { 45 | vertical-align: middle; 46 | } 47 | 48 | .footer-list__link_social { 49 | padding: 5px; 50 | text-decoration: none; 51 | } 52 | 53 | .footer-list__link_social::after { 54 | margin: 0; 55 | } 56 | 57 | .footer-list__link_social:hover { 58 | opacity: 0.7; 59 | } 60 | 61 | .footer-list__icon--invertible { 62 | filter: invert(var(--is-dark-theme-on)); 63 | } 64 | 65 | @media (width >= 1024px) { 66 | .footer { 67 | padding: 20px; 68 | } 69 | } 70 | 71 | @media not all and (width >= 768px) { 72 | .footer { 73 | align-items: flex-start; 74 | flex-direction: column; 75 | padding-block-start: 30px; 76 | } 77 | 78 | .footer__lists { 79 | flex-direction: column; 80 | } 81 | 82 | .footer__theme-toggle, 83 | .footer__list { 84 | flex-direction: column; 85 | } 86 | 87 | .footer__list { 88 | gap: 11px; 89 | } 90 | 91 | .footer__theme-toggle { 92 | gap: 4px; 93 | order: 1; 94 | } 95 | 96 | .footer__list_social { 97 | flex-direction: row; 98 | gap: 10px; 99 | } 100 | } 101 | 102 | @media (width >= 768px) and (width <= 1366px) { 103 | .footer__list { 104 | line-height: 18px; 105 | } 106 | } 107 | --------------------------------------------------------------------------------