Skip to content

Commit

Permalink
[#314]: Blog add TOC and author for post
Browse files Browse the repository at this point in the history
  • Loading branch information
Themezv committed Jul 9, 2023
1 parent 5221c8e commit 943ef39
Show file tree
Hide file tree
Showing 19 changed files with 1,004 additions and 312 deletions.
2 changes: 2 additions & 0 deletions apps/blog/content/memebers/themezv.mdx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
---
username: 'themezv'
fullName: 'Artem Zverev'
avatarFileName: 'themezv.jpg'
title: 'Software Engineer'
---
76 changes: 33 additions & 43 deletions apps/blog/contentlayer.config.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
// @ts-check

import rehypeSlug from 'rehype-slug'
import { addTOCRehypePlugin } from './src/generation-utils/addTOCRehypePlugin'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import { defineDocumentType, makeSource, defineNestedType } from 'contentlayer/source-files'
import { mapHeadingsToTOC } from './src/generation-utils/mapHeadingsToTOC'

const Heading = defineNestedType(() => ({
name: 'Heading',
fields: {
level: { type: 'enum', options: ['h1', 'h2', 'h3', 'h4', 'h5'], required: true },
text: { type: 'string' },
level: { type: 'number', required: true },
value: { type: 'string', required: true },
slug: { type: 'string', required: true },
children: { type: 'nested', of: Heading },
},
}))

Expand Down Expand Up @@ -37,13 +42,9 @@ const computedFields = {
type: 'string',
resolve: doc => extractSlug(doc._raw.flattenedPath),
},
toc: {
type: 'list',
of: Heading,
resolve: () => [{ level: 'h1', text: 'text' }],
},
toc: { type: '[]', of: Heading, resolve: doc => mapHeadingsToTOC(doc._raw.headings) },
lang: {
type: 'enum',
type: "'en' | 'ru'",
options: ['en', 'ru'],
required: true,
resolve: doc => extractFileLanguage(doc._raw.sourceFileName),
Expand Down Expand Up @@ -100,45 +101,34 @@ export const Memeber = defineDocumentType(() => ({
fullName: {
type: 'string',
},
avatarFileName: {
type: 'string',
required: true,
},
title: {
type: 'string',
},
},
}))

export default makeSource({
contentDirPath: 'content',
documentTypes: [BlogPost, Memeber],
// Examples of mdx plugins

// mdx: {
// remarkPlugins: [remarkGfm],
// rehypePlugins: [
// rehypeSlug,
// [
// rehypePrettyCode,
// {
// theme: 'one-dark-pro',
// onVisitLine(node) {
// // Prevent lines from collapsing in `display: grid` mode, and allow empty
// // lines to be copy/pasted
// if (node.children.length === 0) {
// node.children = [{ type: 'text', value: ' ' }]
// }
// },
// onVisitHighlightedLine(node) {
// node.properties.className.push('line--highlighted')
// },
// onVisitHighlightedWord(node) {
// node.properties.className = ['word--highlighted']
// },
// },
// ],
// [
// rehypeAutolinkHeadings,
// {
// properties: {
// className: ['anchor'],
// },
// },
// ],
// ],
// },
mdx: {
remarkPlugins: [],
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: 'wrap',
properties: {
className: ['no-underline hover:underline font-bold text-inherit'],
},
},
],
addTOCRehypePlugin,
],
},
})
8 changes: 6 additions & 2 deletions apps/blog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@
},
"dependencies": {
"@radix-ui/react-dropdown-menu": "^2.0.4",
"contentlayer": "^0.3.1",
"clsx": "^1.2.1",
"contentlayer": "^0.3.4",
"date-fns": "^2.29.3",
"i18next": "^22.4.14",
"i18next-resources-to-backend": "^1.1.3",
"negotiator": "^0.6.3",
"next": "^13.2.4",
"next-contentlayer": "^0.3.1",
"next-contentlayer": "^0.3.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^12.2.0",
Expand All @@ -36,9 +37,12 @@
"autoprefixer": "^10.4.14",
"jsdom": "^22.1.0",
"postcss": "^8.4.21",
"rehype-autolink-headings": "^6.1.1",
"rehype-slug": "^5.1.0",
"schema-dts": "^1.1.2",
"tailwindcss": "^3.3.1",
"typescript": "^5.0.4",
"unist-util-visit": "^4.1.2",
"vitest": "^0.32.0"
}
}
Binary file added apps/blog/public/memebers-avatars/themezv.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 18 additions & 2 deletions apps/blog/src/app/[locale]/posts/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { isPostShouldBePickedByLocale } from '../_utils/isPostShouldBePickedByLo
import { allBlogPostsWithTranslates } from '../_content'
import { generateFullUrl } from '../../../../utils/generateFullUrl'
import { memeberToPostAuthor } from '../../../../utils/memeberToPostAuthor'
import { TOC } from '../../../../components/TOC'
import { PostAuthor } from '../../../../components/PostAuthor'

interface BlogProps {
params: {
Expand Down Expand Up @@ -79,7 +81,7 @@ export default function Post({ params }: BlogProps) {
}

return (
<article className="px-2 md:px-6 max-w-full md:max-w-5xl" lang={post.lang}>
<article className="px-2 md:px-6" lang={post.lang}>
<JsonLDScript jsonLD={jsonLd} />
<p className="text-gray-600 text-sm">{formatDate(post.publishedAt, params.locale)}</p>
<h1 className="font-bold text-3xl my-6 lg:text-5xl lg:font-extrabold">{post.title}</h1>
Expand All @@ -92,7 +94,21 @@ export default function Post({ params }: BlogProps) {
</ChipsRow>
</div>
) : null}
<Mdx code={post.body.code} />
<main className="flex flex-col md:flex-row-reverse relative">
<aside className="md:sticky top-4 h-max mb-6 md:ml-6 md:w-60 lg:w-80 space-y-4">
<PostAuthor
title={postAuthor.title}
avatarUrl={`/memebers-avatars/${postAuthor.avatarFileName}`}
username={postAuthor.username}
fullName={postAuthor.fullName}
/>
{/* @ts-expect-error React Server components */}
<TOC toc={post.toc} locale={params.locale} />
</aside>
<div className="max-w-full md:max-w-5xl">
<Mdx code={post.body.code} />
</div>
</main>
</article>
)
}
19 changes: 19 additions & 0 deletions apps/blog/src/components/PostAuthor/PostAuthor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Image from 'next/image'

interface PostAuthorProps {
avatarUrl: string
username: string
fullName?: string
title?: string
}
export function PostAuthor({ username, fullName, avatarUrl, title }: PostAuthorProps) {
return (
<div className="rounded-lg shadow p-4 w-full h-20 flex space-x-4">
<Image className="rounded-full w-12 h-12" width={48} height={48} src={avatarUrl} alt={fullName || username} />
<div className="overflow-hidden">
<span>{fullName || username}</span> <br />
<span className="font-light text-xs">{title}</span>
</div>
</div>
)
}
1 change: 1 addition & 0 deletions apps/blog/src/components/PostAuthor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PostAuthor } from './PostAuthor'
38 changes: 38 additions & 0 deletions apps/blog/src/components/TOC/TOC.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import clsx from 'clsx'
import type { TOCTree, TOCTreeItem } from '../../types'
import { useTranslation } from '../../i18n'
import type { Language } from '../../i18n/i18n.settings'

interface TOCProps {
toc: TOCTree
locale: Language
}
export async function TOC({ toc, locale }: TOCProps) {
const { t } = await useTranslation(locale, 'post')

return (
<nav className="rounded-lg shadow p-6 w-full">
<header className="font-semibold text-gray-700">{t('tocHeader')}</header>
<ul className="list-inside list-dash font-light text-gray-700 mt-2 marker:tracking-listDash">
{toc.map(heading => (
<HeadingsTree key={heading.value} item={heading} isRoot />
))}
</ul>
</nav>
)
}

function HeadingsTree({ item, isRoot }: { item: TOCTreeItem; isRoot?: boolean }) {
return (
<li className={clsx({ 'pl-6': !isRoot })}>
<a className="hover:underline" href={`#${item.slug}`}>
{item.value}
</a>
{item.children?.map(itemChildren => (
<ul key={itemChildren.value} className="list-inside list-dash">
<HeadingsTree item={itemChildren} isRoot={false} />
</ul>
)) ?? null}
</li>
)
}
1 change: 1 addition & 0 deletions apps/blog/src/components/TOC/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { TOC } from './TOC'
27 changes: 27 additions & 0 deletions apps/blog/src/generation-utils/addTOCRehypePlugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { visit } from 'unist-util-visit'

const headingsSet = new Set(['h2', 'h3', 'h4', 'h5'])

export function addTOCRehypePlugin() {
/**
* Visit heading elements and collect them to array
*
* @param {import('unist').Node<import('unist').Data>} tree
* @param {{data: {rawDocumentData: { headings: import('../types').HeadingsItem[] }}}} file
* @returns void
*/
return (tree, vFile) => {
vFile.data.rawDocumentData.headings = []
visit(
tree,
element => headingsSet.has(element.tagName),
node => {
vFile.data.rawDocumentData.headings.push({
level: +node.tagName[1],
value: node.children[0]?.children[0]?.value,
slug: node.properties.id,
})
},
)
}
}
37 changes: 37 additions & 0 deletions apps/blog/src/generation-utils/mapHeadingsToTOC.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// @ts-check

/**
* Map headings array to TOC tree
*
* @param {import('../types').HeadingsItem[]} headings
* @param {import('../types').TOCTree} tree
* @param currentLevel
* @returns {import('../types').TOCTree} TOC tree
*
* @example
* ```js
* mapHeadingsToTOC([{ level: 2, value: 'Заголовок верхнего уровня' }, { level: 3, value: 'Вложенный заголовок'}, { level: 2, value: 'Заголовок верхнего уровня 2' }, { level: 3, value: 'Вложенный заголовок 1' }, {level: 4, value: 'Вложенный заголовок во вложенный заголовок 1'}])
* // [{level: 2, value: 'Заголовок верхнего уровня', children: [{level: 3, value: 'Вложенный заголовок'}]}, {level: 2, value: 'Заголовок верхнего уровня 2', children: [{depth: 3, value: 'Вложенный заголовок 1', children: [{depth: 4, value: 'Вложенный заголовок во вложенный заголовок 1'}] }]}]
* ```
*/
export function mapHeadingsToTOC(headings, tree = [], currentLevel = 2) {
while (headings.length > 0) {
/**
* @type {import('../types').TOCTreeItem}
*/
const heading = headings[0]
if (heading.level < currentLevel) {
return tree
}
heading.children = []
if (heading.level === currentLevel) {
headings.shift()
tree.push({ ...heading, children: mapHeadingsToTOC(headings, heading.children, currentLevel + 1) })
}
if (heading.level > currentLevel) {
mapHeadingsToTOC(headings, heading.children, currentLevel + 1)
}
}

return tree
}
Loading

0 comments on commit 943ef39

Please sign in to comment.