diff --git a/api/index.ts b/api/index.ts new file mode 100644 index 000000000..92f6ea6f9 --- /dev/null +++ b/api/index.ts @@ -0,0 +1,248 @@ +import path from 'path'; +import fsp from 'fs/promises'; +import { createElement } from 'react'; +import { renderToString } from 'react-dom/server'; +import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; +import matter from 'gray-matter'; +import remarkGfm from 'remark-gfm'; +import rehypePrism from '@mapbox/rehype-prism'; +import rehypeSlug from 'rehype-slug'; +import { serialize } from 'next-mdx-remote/serialize'; +import Feed from 'turbo-rss'; +import findLastIndex from 'lodash.findlastindex'; +import capitalize from 'lodash.capitalize'; +import { parseISO, endOfDay } from 'date-fns'; +import RSSFeedPostContent from '../components/RSSFeedPostContent'; +import { i18n } from '../next-i18next.config'; +import cfg from '../data/config'; + +const makeHref = (pathname: string | null, locale: string): string => { + const parts = ['/']; + + if (locale !== i18n.defaultLocale) { + parts.push(locale); + } + + if (pathname) { + parts.push(pathname); + } + + return path.join(...parts); +}; + +const formatNameToHeader = (name: string): string => { + return name.split('-').map(capitalize).join(' '); +}; + +const readPost = async (filePath: string, basePath: string, locale: string) => { + const fileContent = await fsp.readFile(path.join(basePath, filePath), 'utf-8'); + const matches = getMatches(fileContent); + const contentWithLinks = addLinksToContent(matches, fileContent); + const { data, content } = matter(contentWithLinks); + const { name } = path.parse(filePath); + const { title = null, header = title, description = null, summary = description, ...props } = data; + const sourceUrl = `${cfg.repositoryUrl}/tree/main/${filePath}`; + const shortName = name.slice(11); // remove DD_MM_YYYY prefix from post file name + + const date = endOfDay(parseISO(name.slice(0, 10))); + + // make date UTC + date.setUTCHours(0, 0, 0, 0); + + return { + title, + summary, + content, + sourceUrl, + name: shortName, + header: header || formatNameToHeader(shortName), + date: date.toISOString(), + href: makeHref(shortName, locale), + ...props, + }; +}; + +const compilePostContent = async (content: string) => { + const { compiledSource } = await serialize(content, { + mdxOptions: { + rehypePlugins: [rehypePrism, rehypeSlug], + remarkPlugins: [remarkGfm], + format: 'mdx', + }, + parseFrontmatter: false, + }); + return compiledSource; +}; + +const makePostRSSItem = async (post: any, locale: string) => { + const postUrl = new URL(post.href, cfg.siteURL); + const content = await compilePostContent(post.content); + const breadcrumbs = [ + { + link: cfg.siteURL, + text: cfg.title, + }, + { + link: postUrl.toString(), + text: post.header, + }, + ]; + + const translations = await serverSideTranslations(locale, ['common', 'post']); + const contentElement = createElement(RSSFeedPostContent, { + pageProps: { + ...translations, + }, + content, + href: postUrl.toString(), + }); + + return { + title: post.title, + header: post.header, + author: post.author, + content: renderToString(contentElement), + link: postUrl.toString(), + date: post.date, + breadcrumbs, + extendedHtml: true, + turboEnabled: true, + }; +}; + +export const getPublishedPosts = async (locale: string) => { + const dir = process.cwd(); + const postsPath = path.join('data', 'posts', locale); + const entries = await fsp.readdir(path.join(dir, postsPath), { withFileTypes: true }); + const fileNames = entries + .filter((entry) => entry.isFile()) + .filter(({ name }) => path.extname(name) === '.md') + .map(({ name }) => name); + + const promises = fileNames + .sort((a, b) => a.localeCompare(b)) + .map(async (name) => readPost(path.join(postsPath, name), dir, locale)); + + return await Promise.all(promises); +}; + +export const getPostsList = async (locale: string) => { + const posts = await getPublishedPosts(locale); + + return posts.filter(({ hidden = false }) => !hidden).reverse(); +}; + +export const findPost = async (name: string, locale: string) => { + const posts = await getPublishedPosts(locale); + const postIndex = findLastIndex(posts, (post) => post.name === name); + + if (postIndex === -1) { + return null; + } + + const postsCount = posts.length - 1; + const nextPost = postIndex === postsCount ? posts[0] : posts[postIndex + 1]; + const prevPost = postIndex === 0 ? posts[postsCount] : posts[postIndex - 1]; + + const { content, ...props } = posts[postIndex]; + + return { + ...props, + nextPostData: { name: nextPost.name, header: nextPost.header, href: nextPost.href }, + prevPostData: { name: prevPost.name, header: prevPost.header, href: prevPost.href }, + content: await compilePostContent(content), + }; +}; + +export const generateRssFeed = async (locale: string) => { + const posts = await getPublishedPosts(locale); + const promises = posts.filter(({ hidden = false }) => !hidden).map((post) => makePostRSSItem(post, locale)); + + const feedItems = await Promise.all(promises); + const feed = new Feed({ + title: cfg.title, + link: cfg.siteURL, + description: cfg.description, + language: locale, + }); + + feedItems.forEach((data) => feed.item(data)); + + return feed.xml(); +}; + +export const generateSitemap = async (locale: string) => { + const posts = await getPublishedPosts(locale); + const visiblePosts = posts.filter(({ hidden = false }) => !hidden); + const fields = visiblePosts.map((post) => ({ + loc: new URL(path.join(post.href, '/'), cfg.siteURL), + lastmod: post.date, + 'image:image': `${new URL(post.image, cfg.siteURL)}`, + })); + + fields.push({ + loc: new URL(makeHref(null, locale), cfg.siteURL), + }); + + fields.push({ + loc: new URL(makeHref('about', locale), cfg.siteURL), + }); + + return fields; +}; + +const getMatches = (content: string) => { + const h2Regex = /^## (.*$)/gim; + const ignoreRegex = /`{3}([\w]*)\n([\S\s]+?)\n`{3}/gim; + const contentWithoutMLComments = content.replace(/{\/\*[\s\S]*?\*\/}/gm, ''); + const contentWithoutMarkdown = contentWithoutMLComments.replace(ignoreRegex, ''); + const matches = contentWithoutMarkdown.match(h2Regex); + if (matches) { + return matches; + } + return null; +}; + +const addLinksToContent = (matches: string[] | null, content: string) => { + if (matches) { + const firstHeading = matches[0]; + + const text = matches.map((elem) => { + const length = elem.length; + return elem.slice(3, length); + }); + + const links = text.map((elem) => { + const normilizedLink = elem + .replace(/[^0-9a-zа-я -]/gim, '') + .split(' ') + .join('-') + .toLowerCase(); + return `- [${elem}](#${normilizedLink})`; + }); + + const string = [...links, firstHeading].join('\n'); + const newContent = content.replace(firstHeading, string); + return newContent; + } + return content; +}; + +export const getPostAwailableLocales = async (name: string, locale: string, locales: string[]) => { + /// Getting posts from another locales + const promises = locales + .filter((l) => l !== locale) + .map(async (loc) => ({ loc, posts: await getPublishedPosts(loc) })); + const anotherPosts = await Promise.all(promises); + /// Searching for translated posts to original post + const awailableLocales = anotherPosts + .filter(({ posts }) => findLastIndex(posts, (post) => post.name === name) !== -1) + .map(({ loc }) => loc); + + if (awailableLocales.length === 0) { + return null; + } + ; + + return [locale, ...awailableLocales]; +}; \ No newline at end of file diff --git a/data/banners.ts b/data/banners.ts new file mode 100644 index 000000000..6b598a6ca --- /dev/null +++ b/data/banners.ts @@ -0,0 +1,69 @@ +interface Banner { + content: string; + link: string; +} + +const banners: Record = { + 'course-ansible': { + content: 'Курс по Ansible с практикой прямо в браузере', + link: 'https://ru.hexlet.io/courses/ansible', + }, + 'intensive-devops': { + content: 'Интенсив: Девопс для программистов. Вся база за 3 месяца', + link: 'https://ru.hexlet.io/programs/devops-for-programmers', + }, + 'profession-java': { + content: 'Профессия «Java-разработчик» с нуля и до трудоустройства', + link: 'https://ru.hexlet.io/programs/java', + }, + 'profession-frontend': { + content: 'Профессия «Фронтенд-разработчик» с нуля и до трудоустройства', + link: 'https://ru.hexlet.io/programs/frontend', + }, + 'profession-markup': { + content: 'Профессия «Верстальщик» на Хекслете', + link: 'https://ru.hexlet.io/programs/layout-designer', + }, + 'profession-php': { + content: 'Профессия «PHP-разработчик» с нуля и до трудоустройства', + link: 'https://ru.hexlet.io/programs/php', + }, + 'course-git': { + content: 'Бесплатный курс «Основы Git» с практикой в браузере', + link: 'https://ru.hexlet.io/courses/intro_to_git', + }, + 'course-employment': { + content: 'Курс, помогающий новичкам эффективно составить резюме, попасть на собеседование и пройти его', + link: 'https://ru.hexlet.io/courses/employment', + }, + 'course-os': { + content: 'Курс: «Введение в операционные системы». Бесплатно', + link: 'https://ru.hexlet.io/courses/operating_systems', + }, + 'course-http': { + content: 'Курс по HTTP с практикой в браузере', + link: 'https://ru.hexlet.io/courses/http_protocol', + }, + 'course-cli': { + content: 'Основы командной строки с практикой в браузере. Бесплатно', + link: 'https://ru.hexlet.io/courses/cli-basics', + }, + 'intensive-rails': { + content: 'Интенсив: «Rails-разработчик». Программа для знакомых с программированием', + link: 'https://ru.hexlet.io/programs/rails', + }, + 'intensive-markup': { + content: 'Интенсив: «Верстка веб-приложений». Вся база за 2 месяца', + link: 'https://ru.hexlet.io/programs/layout-designer-basics', + }, + 'site-code-basics': { + content: 'Code Basics: бесплатные курсы программирования', + link: 'https://ru.code-basics.com/', + }, + 'course-racket': { + content: 'Бесплатный курс «Racket как второй язык»', + link: 'https://ru.code-basics.com/languages/racket', + }, +}; + +export default banners; \ No newline at end of file diff --git a/data/config.ts b/data/config.ts new file mode 100644 index 000000000..78cdc254a --- /dev/null +++ b/data/config.ts @@ -0,0 +1,63 @@ +interface SocialLinks { + links: string[]; +} + +interface DisqusConfig { + ru: string; + en: string; +} + +interface AmpAnalytics { + metrikaCounterId: string; + googleTrackingId: string; +} + +interface AmpConfig { + analytics: AmpAnalytics; +} + +interface Config { + siteURL: string; + title: string; + email: string; + timezone: string; + repositoryUrl: string; + siteUrl: string; + tagline: string; + description: string; + social: SocialLinks; + logo: string; + favicon: string; + author: string; + disqus: DisqusConfig; + amp: AmpConfig; +} + +const config: Config = { + siteURL: 'https://guides.hexlet.io', + title: 'Hexlet Guides', + email: 'support@hexlet.io', + timezone: 'UTC', + repositoryUrl: 'https://github.com/Hexlet/hexletguides.github.io', + siteUrl: 'https://guides.hexlet.io', + tagline: 'Гайды', + description: 'Полезные статьи и гайды для разработчиков', + social: { + links: ['https://www.instagram.com/hexlethq/', 'https://www.facebook.com/Hexlet/', 'https://twitter.com/HexletHQ'], + }, + logo: '/images/hexlet_logo.png', + favicon: '/assets/images/favicons/favicon-128.png', + author: 'Kirill Mokevnin', + disqus: { + ru: 'hexlet-guides', + en: 'hexlet-guides-en', + }, + amp: { + analytics: { + metrikaCounterId: '65474386', + googleTrackingId: 'UA-1360700-62', + }, + }, +}; + +export default config; \ No newline at end of file diff --git a/pages/404.tsx b/pages/404.tsx new file mode 100644 index 000000000..b4feeaa63 --- /dev/null +++ b/pages/404.tsx @@ -0,0 +1,60 @@ +import Link from 'next/link'; +import Image from 'next/image'; +import { useTranslation } from 'next-i18next'; +import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; +import LanguageMarkup from '../components/LanguageMarkup'; +import { GetStaticProps } from 'next'; +import React from 'react'; + +interface FourOhFourProps { + languageMarkup: { + awailableLocales: string[]; + defaultLocale: string; + }; +} + +const FourOhFour: React.FC = ({ languageMarkup }) => { + const { t } = useTranslation('404'); + + return ( + <> + +
+
+
+
+ Hexlet logo +
+
+
+
404 - {t('page.message')}
+
+ + {t('page.linkPrefix')} + {' '} + + + {t('page.link')} + + + +
+
+
+
+
+ + ); +}; + +export const getStaticProps: GetStaticProps = async ({ locale, locales, defaultLocale }) => ({ + props: { + languageMarkup: { + awailableLocales: locales, + defaultLocale, + }, + ...await serverSideTranslations(locale, ['404']), + }, +}); + +export default FourOhFour; \ No newline at end of file diff --git a/pages/[name].tsx b/pages/[name].tsx new file mode 100644 index 000000000..ab2289954 --- /dev/null +++ b/pages/[name].tsx @@ -0,0 +1,113 @@ +import { useRouter } from 'next/router'; +import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; +import { GetStaticPaths, GetStaticProps } from 'next'; + +import cfg from '../data/config.js'; +import { i18n } from '../next-i18next.config.js'; +import { findPost, getPublishedPosts, getPostAwailableLocales } from '../api/index.js'; +import DefaultLayout from '../components/DefaultLayout.jsx'; +import PostPageInfo from '../components/PostPageInfo.jsx'; +import MicrometricArticles from '../components/MicrometricArticles.jsx'; +import LanguageMarkup from '../components/LanguageMarkup.jsx'; + +interface Post { + title: string; + summary: string; + author: string; + image: string; + name: string; + redirect_to?: string; +} + +interface LanguageMarkup { + awailableLocales: string[]; + defaultLocale: string; +} + +interface PostProps { + post: Post; + languageMarkup: LanguageMarkup; +} + +const Post: React.FC = ({ post, languageMarkup }) => { + const { locale } = useRouter(); + + if (!post) { + return null; + } + + const disqus = { + short_name: cfg.disqus[locale], + config: { + language: locale, + title: post.title, + identifier: post.name, + }, + }; + + return ( + + + + + +); +}; + +export const getStaticProps: GetStaticProps = async ({ locales, locale, params, defaultLocale }) => { + const post = await findPost(params.name, locale); + + if (!post) { + return { + notFound: true, + }; + } + + if (post.redirect_to) { + return { + redirect: { + permanent: true, + destination: post.redirect_to, + }, + }; + } + + const awailableLocales = await getPostAwailableLocales(params.name, locale, locales); + + return { + props: { + languageMarkup: { awailableLocales, defaultLocale }, + post, + ...(await serverSideTranslations(locale, ['common', 'post'])), + }, + }; +}; + +export const getStaticPaths: GetStaticPaths = async () => { + const promises = i18n.locales.map(async (locale) => { + const posts = await getPublishedPosts(locale); + + return posts + .filter(({ redirect_to }) => !redirect_to) + .map(({ name }) => ({ + locale, + params: { name }, + })); + }); + + const allPaths = await Promise.all(promises); + const paths = allPaths.flat(); + + return { + paths, + fallback: true, + }; +}; + +export default Post; \ No newline at end of file diff --git a/pages/_app.ts b/pages/_app.ts new file mode 100644 index 000000000..8916c95b7 --- /dev/null +++ b/pages/_app.ts @@ -0,0 +1,9 @@ +import 'bootstrap/dist/css/bootstrap.min.css'; +import '../styles/theme.scss'; +import '../styles/prism.css'; +import '../styles/custom.css'; +import { appWithTranslation, AppProps } from 'next-i18next'; + +const App = ({ Component, pageProps }: AppProps) => ; + +export default appWithTranslation(App); \ No newline at end of file diff --git a/pages/_error.ts b/pages/_error.ts new file mode 100644 index 000000000..402e1786c --- /dev/null +++ b/pages/_error.ts @@ -0,0 +1,44 @@ +/** + * NOTE: This requires `@sentry/nextjs` version 7.3.0 or higher. + * + * NOTE: If using this with `next` version 12.2.0 or lower, uncomment the + * penultimate line in `CustomErrorComponent`. + * + * This page is loaded by Nextjs: + * - on the server, when data-fetching methods throw or reject + * - on the client, when `getInitialProps` throws or rejects + * - on the client, when a React lifecycle method throws or rejects, and it's + * caught by the built-in Nextjs error boundary + * + * See: + * - https://nextjs.org/docs/basic-features/data-fetching/overview + * - https://nextjs.org/docs/api-reference/data-fetching/get-initial-props + * - https://reactjs.org/docs/error-boundaries.html + */ + +import * as Sentry from '@sentry/nextjs'; +import NextErrorComponent from 'next/error'; +import { NextPageContext } from 'next'; + +interface CustomErrorComponentProps { + statusCode: number; +} + +const CustomErrorComponent: React.FC = (props) => { + // If you're using a Nextjs version prior to 12.2.1, uncomment this to + // compensate for https://github.com/vercel/next.js/issues/8592 + // Sentry.captureUnderscoreErrorException(props); + + return ; +}; + +CustomErrorComponent.getInitialProps = async (contextData: NextPageContext) => { + // In case this is running in a serverless function, await this in order to give Sentry + // time to send the error before the lambda exits + await Sentry.captureUnderscoreErrorException(contextData); + + // This will contain the status code of the response + return NextErrorComponent.getInitialProps(contextData); +}; + +export default CustomErrorComponent; \ No newline at end of file diff --git a/pages/about.ts b/pages/about.ts new file mode 100644 index 000000000..201ed71bf --- /dev/null +++ b/pages/about.ts @@ -0,0 +1,44 @@ +import { useTranslation } from 'next-i18next'; +import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; +import type { GetStaticProps } from 'next'; + +import DefaultLayout from '../components/DefaultLayout'; +import LanguageMarkup from '../components/LanguageMarkup'; + +interface AboutProps { + languageMarkup: { + awailableLocales: string[]; + defaultLocale: string; + }; +} + +const About: React.FC = ({ languageMarkup }) => { + const { t } = useTranslation('about'); + + return ( + + +
+
+

{t('page.title')}

+
+
+

{t('page.p1')}

+

+

+
+
+ ); +}; + +export const getStaticProps: GetStaticProps = async ({ locale, locales, defaultLocale }) => ({ + props: { + languageMarkup: { + awailableLocales: locales, + defaultLocale, + }, + ...(await serverSideTranslations(locale, ['common', 'about'])), + }, +}); + +export default About; diff --git a/pages/index.ts b/pages/index.ts new file mode 100644 index 000000000..f8310959d --- /dev/null +++ b/pages/index.ts @@ -0,0 +1,40 @@ +import { useTranslation } from 'next-i18next'; +import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; +import { GetStaticProps } from 'next'; + +import { getPostsList } from '../api/index'; +import HomePageInfo from '../components/HomePageInfo'; + +interface LanguageMarkupProps { + awailableLocales: string[]; + defaultLocale: string; +} + +interface HomeProps { + posts: any[]; // Replace 'any' with the actual type of your posts + languageMarkup: LanguageMarkupProps; +} + +const Home: React.FC = ({ posts, languageMarkup }) => { + const { t } = useTranslation('common'); + + return ( + + + + + ); +}; + +export const getStaticProps: GetStaticProps = async ({ locale, locales, defaultLocale }) => ({ + props: { + languageMarkup: { + awailableLocales: locales, + defaultLocale, + }, + posts: await getPostsList(locale), + ...(await serverSideTranslations(locale, ['common'])), + }, +}); + +export default Home; \ No newline at end of file diff --git a/pages/sentry_sample_error.ts b/pages/sentry_sample_error.ts new file mode 100644 index 000000000..13ff213bb --- /dev/null +++ b/pages/sentry_sample_error.ts @@ -0,0 +1,50 @@ +import Head from 'next/head'; + +const boxStyles: React.CSSProperties = { padding: '12px', border: '1px solid #eaeaea', borderRadius: '10px' }; + +const Home: React.FC = () => { + return ( +
+ + Sentry Onboarding + + + +
+

+ + + +

+ +

+ Get started by sending us a sample error +

+ + +

+ For more information, see https://docs.sentry.io/platforms/javascript/guides/nextjs/ +

+
+
+ ); +}; + +export default Home; diff --git a/public/assets/js/couters.ts b/public/assets/js/couters.ts new file mode 100644 index 000000000..01b63d862 --- /dev/null +++ b/public/assets/js/couters.ts @@ -0,0 +1,10 @@ +(function(w: Window, d: Document, s: string, l: string, i: string): void { + w[l] = w[l] || []; + w[l].push({'gtm.start': new Date().getTime(), event: 'gtm.js'}); + const f: HTMLScriptElement = d.getElementsByTagName(s)[0]; + const j: HTMLScriptElement = d.createElement(s); + const dl: string = l !== 'dataLayer' ? '&l=' + l : ''; + j.async = true; + j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl; + f.parentNode.insertBefore(j, f); +})(window, document, 'script', 'dataLayer', 'GTM-KMJ8HKG'); \ No newline at end of file