Forsythe

Как я разрабатывал свой сайт-портфолио?

Nov 23, 2024 — 8 мин

Делюсь опытом в разработке Full-stack приложения на примере своего блога, который использует Framer Motion, MDX Remote, Tailwind CSS Typograhy и плагины Rehype.

Как я разрабатывал свой сайт-портфолио?

Введение

В ноябре 2024 года мне в голову пришла идея о создании полноценного сайта-портфолио, который бы включал интерактивный блог со специальной разметкой для читателей.

Основная концепция заключалась в том, чтобы написать максимально простой и надежный код без необходимости интеграции с внешним CMS, такими как WordPress, Joomla, Drupal, Strapi и другие. Управление контентом блога и некоторых других модулей планировалось реализовать при помощи удаленных файлов разметки формата .mdx.

MDX — это формат, который позволяет встраивать компоненты React прямо в Markdown-документы. Это делает его идеальным для создания интерактивных статей и документации.

В чем преимущество MDX?

  • Компоненты: Можно вставлять React-компоненты прямо в разметку.
  • Простота: Комбинация React и Markdown упрощает процесс написания статей блога.
  • Интерактивность: Возможность добавления отдельных интерактивных элементов.
  • Автономность: При желании, можно управлять контентом не прибегая к коду.

О технологиях

В качестве фреймворка был выбран NextJS 15 на базе React 19.

UI библиотеки

  • Shadcn — универсальная библиотека «готовых» компонентов
  • Tailwind CSS — до боли знакомый всем CSS-фреймворк
  • Next Themes — библиотека для работы с цветовой схемой приложения
  • Framer Motion — библиотека анимированных компонентов

MDX библиотеки

  • Next MDX Remote — особый пакет для работы с .mdx, который рекомендует NextJS
  • Rehype Pretty Code и Shiki — набор плагинов для подсветки синтаксиса кода в Markdown
  • Tailwind CSS Typography — специальный модуль стилей для типографии

Другие библиотеки

  • Nodemailer — Node.js библиотека для отправки писем
  • React Hook Form — библиотека для работы с формами
  • Zod — библиотека для валидации схем

По мере дальнейшего развития сайта могут появиться другие мажорные плагины, пакеты, библиотеки или даже фреймворки...

Как я придумывал дизайн

Поскольку у меня были свои планы относительно сроков релиза проекта, пришлось остановиться на более простом и строгом варианте. Концепция дизайна, от главной страницы до раздела с блогом, была придумана и разработана мной с нуля.

В ходе разработки адаптивного дизайна использовался принцип «как у всех». За основу взяты макеты самых распространенных мобильных и широкоэкранных разрешений.

Про Front-end

В качестве базового набора UI были выбраны готовые компоненты из библиотеки shadcn/ui: кнопки, элементы формы, бейджики, «хлебные крошки» и т.д.

Почему Shadcn?

Я думаю, в этом выборе сыграло роль сразу несколько факторов, но самыми значимыми для меня были удобство в использовании и относительно нейтральная, строгая стилизация.

Для меня было важно, чтобы библиотечные компоненты не выделялись среди остальных и гармонично вписывались в дизайн интерфейса.

О Framer Motion

Я впервые применял компоненты framer-motion на своем опыте, так что мне потребовалось некоторое время на изучение их великолепной документации.

Библиотека оказалась не сложной, и при помощи нескольких анимаций я создал очень крутой переход между страницами.

page-animation.tsx
'use client'
 
import React from 'react'
import { usePathname } from 'next/navigation'
 
import { motion, AnimatePresence } from 'framer-motion'
 
export const PageAnimation: React.FC<React.PropsWithChildren> = ({ children }) => {
  const pathname = usePathname()
 
  return (
    <>
      <AnimatePresence mode="wait">
        <div key={pathname} className="w-screen h-screen inset-0 z-50 pointer-events-none fixed">
          <motion.div
            className="w-full h-full bg-foreground origin-bottom absolute"
            initial={{ scaleY: 0 }}
            animate={{ scaleY: 0 }}
            exit={{ scaleY: 1 }}
            transition={{ duration: 0.3, ease: [0.2, 1, 0.35, 1] }}
          />
          <motion.div
            className="w-full h-full bg-foreground origin-top absolute"
            initial={{ scaleY: 1 }}
            animate={{ scaleY: 0 }}
            exit={{ scaleY: 0 }}
            transition={{ duration: 0.3, ease: [0.2, 1, 0.35, 1] }}
          />
        </div>
      </AnimatePresence>
 
      <AnimatePresence>
        <motion.div
          key={pathname}
          className="w-screen h-screen inset-0 z-10 bg-background pointer-events-none fixed overflow-hidden"
          initial={{ opacity: 1 }}
          animate={{ opacity: 0, transition: { duration: 0.5, delay: 0.3, ease: 'easeOut' } }}
        />
        {children}
      </AnimatePresence>
    </>
  )
}

И обернул им все страницы моего приложения.

layout.tsx
export default function AppLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html className="scroll-smooth scrollbar" lang="ru" suppressHydrationWarning>
      <body
        className={cn(
          'antialiased',
          inter.variable,
          raleway.variable,
          syne.variable,
          jetBrainsMono.variable
        )}
      >
        <Providers>
          <PageAnimation>{children}</PageAnimation>
        </Providers>
      </body>
    </html>
  )
}

Альтернативная тема

Документация Shadcn также включает в себя рекомендации по подключению темного режима в приложении при помощи отдельного пакета next-themes.

Был создан отдельный компонент-провайдер для темы.

theme-provider.tsx
'use client'
 
import React from 'react'
 
import { ThemeProvider as NextThemesProvider } from 'next-themes'
 
type ThemeProviderProps = typeof NextThemesProvider
 
export const ThemeProvider: React.FC<React.ComponentProps<ThemeProviderProps>> = ({
  children,
  ...props
}) => {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

Как и любой другой провайдер, он является клиентской частью приложения и по-классике будет одним из слоев в компоненте с другими возможными провайдерами.

providers.tsx
'use client'
 
import React from 'react'
 
import { ThemeProvider } from '@/components'
 
export const Providers: React.FC<React.PropsWithChildren> = ({ children }) => {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
      {children}
    </ThemeProvider>
  )
}

Подключение Nodemailer

Весьма полезной библиотекой оказался nodemailer, который сильно облегчил возможность генерации feedback-писем на моем почтовом ящике.

Конфигурация позволит подключить почтовый ящик с вашими данными к рассылке писем.

mailer.config.ts
import nodemailer from 'nodemailer'
 
const SMTP_EMAIL = process.env.SMTP_EMAIL
const SMTP_PASSWORD = process.env.SMTP_PASSWORD
 
export const transporter = nodemailer.createTransport({
  service: 'gmail',
  port: 587,
  secure: false,
  auth: {
    user: SMTP_EMAIL,
    pass: SMTP_PASSWORD,
  },
})

Функция send-mail является серверной, поскольку она использует файловую систему и handlebars. Я использую отдельный тип и интерфейс, чтобы понимать какие динамические значения будут в шаблоне HTML у отправляемого письма.

send-mail.ts
'use server'
 
import * as fs from 'fs'
import * as handlebars from 'handlebars'
 
import { transporter } from '@/config/mailer.config'
 
const SMTP_EMAIL = process.env.SMTP_EMAIL
 
type Replacements = Record<string, string | undefined>
 
interface ISendMailOptions {
  from: string
  to: string
  subject: string
  text?: string
  html?: {
    template: string
    replacements: Replacements
  }
}
 
export async function sendMail(options: ISendMailOptions) {
  try {
    let html = undefined
 
    if (options.html) {
      const source = fs.readFileSync(options.html.template, 'utf-8').toString()
      if (source) {
        const template = handlebars.compile(source)
        html = template(options.html.replacements)
      }
    }
 
    return transporter.sendMail({
      from: `${options.from} <${SMTP_EMAIL}>`,
      to: options.to,
      subject: options.subject,
      text: options.text,
      html: html,
    })
  } catch (error) {
    console.error('sendMail: Failed to send mail', error)
  }
}

Разработка блога

При разработке блога я ориентировался на рекомендации по работе с MDX от NextJS.

Функция getBlogPost достает содержимое файла в директории с контентом и компилирует frontmatter и content из MDX:

  • Frontmatter — это метаданные, полученные из заголовков документа .mdx.
  • Content — содержимое разметки Markdown и React-компонентов.

Доставать содержимое разметки Markdown можно различными способами, например, при помощи библиотек gray-matter и markdown-to-jsx, но документация NextJS рекомендует делать это именно при помощи пакета next-mdx-remote.

get-blog-post.ts
import fs from 'fs'
import path from 'path'
 
import { calcReadingTime } from '@/lib/utils'
import { CONTENT_DIR } from '@/lib/get-blog-metadata'
import { compileMDX } from 'next-mdx-remote/rsc'
 
import type { BlogFrontmatterType, BlogType } from '@/types'
 
const rehypePrettyCodeOptions: Options = {
  theme: 'dark-plus',
  defaultLang: 'md',
}
 
export async function getBlogPost(slug: string): Promise<BlogType | undefined> {
  const fileName = slug + '.mdx'
  const filePath = path.join(CONTENT_DIR, fileName)
 
  const isFile = fs.existsSync(filePath)
 
  if (!isFile) return undefined
 
  const file = fs.readFileSync(filePath, 'utf-8')
 
  const { frontmatter, content } = await compileMDX<BlogFrontmatterType>({
    source: file,
    options: {
      parseFrontmatter: true,
    },
  })
 
  if (frontmatter.isPublished === false) return undefined
 
  return {
    slug: path.parse(fileName).name,
    frontmatter,
    content,
    reading: calcReadingTime(file),
  }
}

Функция getBlogMetadata выполнит запрос на получение всех статей из директории с контентом. Перед тем как вернуть результат, она очистит массив от неопределенных значений и отсортирует его по дате создания.

get-blog-metadata.ts
import fs from 'fs'
import path from 'path'
 
import { getBlogPost } from '@/lib/get-blog-post'
 
import type { BlogType } from '@/types'
 
export const CONTENT_DIR = path.join(process.cwd(), 'src/content/blog')
 
export async function getBlogMetadata(): Promise<BlogType[] | []> {
  const files = fs.readdirSync(CONTENT_DIR)
 
  const metadata = await Promise.all(
    files.map(async (file) => await getBlogPost(path.parse(file).name))
  )
 
  const blog = metadata.filter((file) => file !== undefined)
 
  return blog.sort((one, two) => {
    const dateOne = new Date(one.frontmatter.createdAt)
    const dateTwo = new Date(two.frontmatter.createdAt)
 
    return dateTwo.getDate() - dateOne.getDate()
  })
}

Static Site Generation (SSG)

Функция generateStaticParams сгенерирует все динамические маршруты статей блога еще во время сборки приложения, а не по каждому запросу отдельно. Это избавит серверную сторону от лишних запросов, а также значительно ускорит отклик на сайте.

Генерация динамических данных с помощью generateStaticParams будет менее эффективна для данных, которые требуют постоянного обновления для поддержания актуальности информации.

blog/[slug].tsx
const getData = cache(async (slug: string) => {
  return getBlogPost(slug)
})
 
export async function generateStaticParams() {
  const metadata = await getBlogMetadata()
 
  return metadata.map((post) => ({
    slug: post.slug,
  }))
}

Типографика

Для применения стандартных типографических стилей к контенту Markdown был подключен модуль Tailwind CSS — @tailwind/typography.

tailwind.config.ts
import type { Config } from 'tailwindcss'
 
export default {
  // ...
  plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
} satisfies Config

Применив css-свойство prose на родительский блок содержимого MDX, Tailwind создаст для вложенных элементов особые типографические стили.

blog-post.tsx
<div ref={contentRef} className="max-w-full prose dark:prose-invert">
  {content}
</div>

Подключение Rehype-плагинов

Установка плагина rehype-pretty-code позволила более тонко стилизовать подсветку синтаксиса кода при использовании модуля Tailwind CSS Typography.

Для начала можно определить конфигурацию типа Options, импортированного из пакета rehype-pretty-code.

get-blog-post.ts
const rehypePrettyCodeOptions: Options = {
  theme: 'dark-plus',
  defaultLang: 'md',
}

Подключить сам плагин можно внутри объекта mdxOptions.

get-blog-post.ts
const { frontmatter, content } = await compileMDX<BlogFrontmatterType>({
  source: file,
  options: {
    parseFrontmatter: true,
    mdxOptions: {
      rehypePlugins: [[rehypePrettyCode, rehypePrettyCodeOptions]],
      format: 'mdx',
    },
  },
})

Плагин rehype устанавливает значения в data-атрибуты элементов синтаксиса кода, поэтому, при желании, можно стилизовать практически любые эффекты.

Это одна из немногих причин, по которой я выбрал именно rehype-pretty-code.

globals.css
[data-rehype-pretty-code-fragment] [data-line]::before {
  color: rgba(255, 255, 255, 0.5);
  counter-increment: line;
  content: counter(line);
  display: inline-block;
  width: 1rem;
  margin-right: 1rem;
  text-align: right;
}
 
code[data-line-numbers] {
  counter-reset: line;
}
 
code[data-line-numbers] > [data-line]::before {
  counter-increment: line;
  content: counter(line);
  display: inline-block;
  width: 0.75rem;
  margin-right: 2rem;
  text-align: right;
  color: gray;
}

SEO-оптимизация

Для обеспечения поисковой оптимизации я обратился к официальной документации от NextJS, которая подробно объясняла как использовать API метаданных, чтобы определять мета-теги и улучшать SEO сайта.

Функция generateMetadata генерирует динамические метаданные для маршрутов приложения и возвращает Promise с результатом асинхронной операции.

library/[slug].tsx
export async function generateMetadata({ params }: ILibraryPage): Promise<Metadata> {
  const { slug } = await params
  const repo = await getData(slug)
 
  if (!repo) notFound()
 
  return {
    title: repo.name,
    description: repo.description || repo.full_name,
  }
}

Генерация карты сайта (sitemap) также является важным фактором для SEO-оптимизации.

Функция sitemap, которую рекомендует использовать документация NextJS, перечисляет все возможные маршруты приложения, в том числе и динамические.

sitemap.ts
import { MetadataRoute } from 'next'
 
import { getBlogMetadata } from '@/lib/get-blog-metadata.ts'
 
import { Route } from '@/config/routes.config.ts'
 
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const blog = await getBlogMetadata()
 
  const metadata: MetadataRoute.Sitemap = blog.map(({ slug, createdAt }) => ({
    url: `${Route.BLOG}/${slug}`,
    lastModified: new Date(createdAt),
  }))
 
  return [
    {
      url: Route.HOME,
      lastModified: new Date(),
    },
    {
      url: Route.RESUME,
      lastModified: new Date(),
    },
    {
      url: Route.LIBRARY,
      lastModified: new Date(),
    },
    {
      url: Route.CONTACTS,
      lastModified: new Date(),
    },
    {
      url: Route.FEEDBACK,
      lastModified: new Date(),
    },
    {
      url: Route.BLOG,
      lastModified: new Date(),
    },
 
    ...metadata,
  ]
}

Функция robots помогает понять поисковым машинам к каким маршрутам они могут иметь доступ, а к каким — нет. Неразрешенные пути не будут индексироваться при поисковом запросе, соответственно не отобразятся в результате поиска.

robots.ts
import { MetadataRoute } from 'next'
 
import { Route } from '@/config/routes.config.ts'
 
export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
    },
    sitemap: Route.SITEMAP,
  }
}

Заключение

На этом моменте заканчиваются все те этапы разработки, которые показались мне наиболее интересными и необычными, и которыми бы я хотел поделиться в первую очередь.

Содержимое статьи актуально для последней минорной версии 1.1. Отслеживать все последующие изменения можно в моем репозитории и в разделе с релизами.

В этой статье
    Поделиться ссылкой