Skip to content

Commit ab5f0e8

Browse files
committed
Make layout for blog posts
1 parent bc3eb85 commit ab5f0e8

28 files changed

+622
-195
lines changed

package-lock.json

+287-57
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"@hookform/resolvers": "^2.9.11",
1818
"class-variance-authority": "^0.4.0",
1919
"framer-motion": "^9.1.7",
20+
"linkedom": "^0.14.25",
2021
"next": "13.2.4",
2122
"nextjs-google-analytics": "^2.3.3",
2223
"react": "18.2.0",
@@ -42,4 +43,4 @@
4243
"postcss": "^8.4.21",
4344
"tailwindcss": "^3.2.7"
4445
}
45-
}
46+
}

scripts/generateArticlesIndex.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ const run = async () => {
2020
.forEach((articleFilename, i, arr) => {
2121
const importName = `article${i + 1}`;
2222
fileImports += `import ${importName} from "./${articleFilename}";\n`;
23-
exportArrayContent += `\t${importName}${arr.length - 1 === i ? "" : ","}\n`;
23+
exportArrayContent += `\t{ \n\t\tfilename: "${articleFilename}", \n\t\tarticle: ${importName} \n\t}${arr.length - 1 === i ? "" : ","}\n`;
2424
});
2525

26-
exportArrayContent += "] as IArticle[];";
26+
exportArrayContent += "] as { filename: string; article: IArticle }[];";
2727

2828
const indexContent = `${fileImports}\n${exportArrayContent}`;
2929
await fs.writeFile(indexPath, indexContent);

src/assets/state/articles/1.tsx

+11-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { people } from "../team";
33
import tags from "./tags";
44

55
import thumbnail from "@/assets/images/content/blog/1/thumbnail.webp";
6+
import ArticleTableOfContent from "@/components/pages/blog/post/ArticleTableOfContent";
67

78
export default {
89
title: "1",
@@ -22,7 +23,16 @@ export default {
2223

2324
teaser: "Lorem ipsum dolor sit amet consectetur Voluptates facere quasi repellat doloremque quae saepe?",
2425
content: <>
25-
1
26+
<h2>Heading 2</h2>
27+
<h3>Heading 3</h3>
28+
<h4>Heading 4</h4>
29+
<h5>Heading 5</h5>
30+
31+
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Aut quia facilis dolor ullam distinctio tempora delectus laudantium similique. Eos mollitia maxime nam id nemo repellendus natus accusamus dicta quam illum.
32+
<ArticleTableOfContent />
33+
34+
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Rem aliquam illum aut atque tempore quod repudiandae ad maiores molestias? Maxime animi at incidunt omnis rem nostrum, ipsum ab molestias deleniti.
35+
Lorem ipsum dolor sit amet consectetur adipisicing elit. Harum recusandae laborum fugit quo iusto culpa animi ab cupiditate cumque. Labore modi rem, enim molestias sint eaque porro velit facilis excepturi?
2636
</>,
2737

2838
created: new Date("2023/03/24")

src/assets/state/articles/index.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import article1 from "./1";
44
import article2 from "./2";
55

66
export default [
7-
article1,
8-
article2
9-
] as IArticle[];
7+
{
8+
filename: "1",
9+
article: article1
10+
},
11+
{
12+
filename: "2",
13+
article: article2
14+
}
15+
] as { filename: string; article: IArticle }[];

src/assets/state/roadmap.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import Heading from "@/components/layout/heading";
1+
import Heading from "@/components/layout/Heading";
22
import Link from "@/components/navigation/Link";
33
import { ReactNode } from "react";
44

@@ -83,4 +83,4 @@ export default [
8383
</p>
8484
</>
8585
}
86-
]satisfies IRoadmapItem[];
86+
] satisfies IRoadmapItem[];

src/components/pages/blog/ArticleBrief.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const ArticleBrief = ({ title, thumbnail, thumbnailAlt, readtime, teaser, author
5050

5151
return (
5252
<motion.li
53-
className="origin-center flex flex-col flex-1 gap-4 mx-0 rounded-lg motion-safe:transition-[margin-inline] motion-safe:duration-500 sm:mx-16 md:mx-0 bg-primary/20"
53+
className="origin-center flex flex-col flex-1 gap-4 mx-0 rounded-lg motion-safe:transition-[margin-inline] motion-safe:duration-500 sm:mx-16 md:mx-0 bg-primary-300"
5454
variants={listAnim}
5555
initial="in"
5656
animate="anim"
@@ -72,17 +72,17 @@ const ArticleBrief = ({ title, thumbnail, thumbnailAlt, readtime, teaser, author
7272
<LinkButton
7373
key={i}
7474
href={makeTagUrl(tag)}
75+
color="secondary"
7576
className="px-2 py-1 text-xs font-semibold border"
76-
color="primary"
7777
>
7878
{tag}
7979
</LinkButton>
8080
))}
8181
</div>}
82-
<Link href={url} color="fill-contrast" className="block text-lg font-bold">
82+
<Link href={url} color="fill-contrast" className="block text-2xl font-bold">
8383
{title}
8484
</Link>
85-
<Link href={url} color="fill-contrast" className="flex-1">
85+
<Link href={url} color="fill-contrast" className="flex-1 ">
8686
{teaser}
8787
</Link>
8888
<div className="flex flex-col flex-wrap gap-4 pt-4 mt-auto">
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import IArticle from "@/assets/state/articles/article";
2+
import { createContext, useContext } from "react";
3+
4+
export const ArticleContext = createContext<ArticleContext>({ ready: false });
5+
6+
export type ArticleContext = ({
7+
ready: true;
8+
headings: {
9+
text: string;
10+
level: number;
11+
id: string;
12+
}[];
13+
path: string;
14+
} & IArticle) | {
15+
ready: false;
16+
};
17+
18+
export const useArticle = () => useContext(ArticleContext);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import React from "react";
2+
import BookIcon from "@heroicons/react/24/outline/BookOpenIcon";
3+
import CalendarIcon from "@heroicons/react/24/solid/CalendarIcon";
4+
5+
import { IMember } from "@/assets/state/team";
6+
7+
import useSSGSafe from "@/hooks/useSSGSafe";
8+
9+
import Link from "@/components/navigation/Link";
10+
import { readtimeFormatter } from "@/utils/readtime";
11+
12+
export interface ArticleMetaProps {
13+
author: IMember;
14+
readtime: number;
15+
created: string;
16+
updated?: string | null;
17+
}
18+
19+
const DotSeparator = <div className="hidden w-1 h-1 rounded-full bg-neutral-600 md:block" />;
20+
21+
const ArticleMeta = ({ author, readtime, created, updated }: ArticleMetaProps) => {
22+
const safeToRender = useSSGSafe();
23+
const AuthorTag = author.links.length > 0 ? Link : "div";
24+
25+
return (
26+
<div className="flex flex-wrap items-center justify-center gap-4">
27+
<AuthorTag
28+
className="flex items-center gap-2 group/author"
29+
//@ts-ignore
30+
href={author.links.length > 0 ? author.links[0].href : undefined}
31+
//@ts-ignore
32+
color={author.links.length > 0 ? "fill-contrast" : undefined}
33+
data-is-link={author.links.length > 0}
34+
>
35+
<div className="overflow-hidden rounded-full">
36+
<img
37+
className="object-contain w-8 h-8 rounded-full aspect-square group-hover/author:scale-110 motion-safe:transition-all"
38+
src={author.image.src}
39+
width={author.image.width}
40+
height={author.image.height}
41+
alt="A picture of the author"
42+
/>
43+
</div>
44+
<div className="flex flex-col">
45+
<div>{author.fullName}</div>
46+
<div className="text-sm">{author.title}</div>
47+
</div>
48+
</AuthorTag>
49+
{DotSeparator}
50+
<div className="flex items-center gap-4 text-sm">
51+
{safeToRender && <div className="flex items-center gap-2">
52+
<BookIcon className="w-4 h-4" />
53+
<time
54+
aria-label="reading time"
55+
dateTime={`${readtime} minutes`}
56+
>
57+
{readtimeFormatter.format(readtime)}
58+
</time>
59+
</div>}
60+
{DotSeparator}
61+
{safeToRender && <div className="flex items-center gap-2">
62+
<CalendarIcon className="w-4 h-4" />
63+
<time aria-label="Date created" dateTime={created} className={`${updated ? "hidden" : ""}`}>
64+
{(new Date(created)).toLocaleDateString()}
65+
</time>
66+
{updated && <time aria-label="Date updated" dateTime={updated}>
67+
{(new Date(updated)).toLocaleDateString()}
68+
</time>}
69+
</div>}
70+
</div>
71+
</div>
72+
);
73+
};
74+
75+
export default ArticleMeta;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import Link from "@/components/navigation/Link";
2+
import { useArticle } from "./ArticleContext";
3+
4+
export interface TableOfContentProps {
5+
/**
6+
* Decides the minimum level a heading should have to appear in the table of contents
7+
* @default 2
8+
*/
9+
minLevel?: number;
10+
11+
/**
12+
* Decides the maximum level a heading should have to appear in the table of contents
13+
* @default 4
14+
*/
15+
maxLevel?: number;
16+
}
17+
18+
const ArticleTableOfContent = ({ minLevel = 2, maxLevel = 4 }: TableOfContentProps) => {
19+
20+
const article = useArticle();
21+
22+
23+
return (
24+
<ul>
25+
{article.ready && article.headings.map((heading, i) => {
26+
if (heading.level < minLevel || heading.level > maxLevel) return null;
27+
28+
const url = new URL(article.path, "http://example.com");
29+
url.hash = heading.id;
30+
31+
return <li key={i}>
32+
<Link color="primary" href={url.toString().replace("http://example.com", "")} underline>
33+
{heading.text}
34+
</Link>
35+
</li>;
36+
})}
37+
</ul>
38+
);
39+
};
40+
41+
export default ArticleTableOfContent;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { ArticleContext } from "../post/ArticleContext";
2+
import IArticle from "@/assets/state/articles/article";
3+
4+
const makeHeadingId = (heading: Element, index: number, usedHeadingIds: string[]) => {
5+
const id = heading.getAttribute("id");
6+
if (id) {
7+
if (usedHeadingIds.includes(id)) {
8+
console.warn(`Duplicate ID "${id}"`);
9+
}
10+
usedHeadingIds.push(id);
11+
return id;
12+
}
13+
14+
const contentId = (heading.textContent ?? "").substring(0, 255).replace(/[\s\n\t_]/g, "-");
15+
const newId = usedHeadingIds.includes(contentId) ? `${contentId}-${index}` : contentId;
16+
usedHeadingIds.push(newId);
17+
18+
return newId;
19+
};
20+
21+
/**
22+
* **SHOULD ONLY BE USED ON THE SERVER-SIDE**
23+
*
24+
* Renders the JSX elements out to static HTML.
25+
* Content is rendered twice. A first time without ArticleContext and a second time with.
26+
* The first render is to provide information about the content, like headings.
27+
* The second render has the context provided, and assigns ids to all headings that have none
28+
*
29+
*/
30+
export const makeStaticContent = async (article: IArticle, postId: number) => {
31+
const { parseHTML } = await import("linkedom");
32+
const { reactNodeToString } = await import("@/utils/react");
33+
34+
const firstRenderContent = await reactNodeToString(article.content);
35+
36+
const firtsRender = parseHTML(firstRenderContent);
37+
let usedHeadingIds: string[] = [];
38+
const headings = Array
39+
.from(firtsRender.document.querySelectorAll("h1, h2, h3, h4, h5, h6"))
40+
.map((heading, i) => ({
41+
level: Number(heading.nodeName.substring(1)),
42+
text: heading.textContent!,
43+
id: makeHeadingId(heading, i, usedHeadingIds)
44+
}));
45+
46+
const secondRenderContent = await reactNodeToString(
47+
<ArticleContext.Provider
48+
value={Object.assign({
49+
ready: true,
50+
headings,
51+
path: `/blog/${postId}/${article.slug}`
52+
}, article)}
53+
>
54+
{article.content}
55+
</ArticleContext.Provider>
56+
);
57+
58+
const secondRender = parseHTML(secondRenderContent);
59+
usedHeadingIds = [];
60+
Array.from(secondRender.document.querySelectorAll("h1, h2, h3, h4, h5, h6"))
61+
.forEach((heading, i) => {
62+
heading.setAttribute("id", makeHeadingId(heading, i, usedHeadingIds));
63+
});
64+
65+
return secondRender.document.toString();
66+
};

src/components/pages/contribute/FeedbackSection.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import Link from "@/components/navigation/Link";
1010
import { backend } from "@/utils/wretch";
1111
import TextArea from "@/components/controls/TextArea";
1212
import Form from "@/components/controls/Form";
13-
import Heading from "@/components/layout/heading";
13+
import Heading from "@/components/layout/Heading";
1414

1515
const feedbackSchema = z.object({
1616
text: z.string()

src/components/pages/front/Mission.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { IMission } from "@/assets/state/missions";
2-
import Heading from "@/components/layout/heading";
2+
import Heading from "@/components/layout/Heading";
33

44
export interface MissionProps extends IMission {
55

src/components/pages/front/RoadmapItem.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { IRoadmapItem } from "@/assets/state/roadmap";
2-
import Heading from "@/components/layout/heading";
2+
import Heading from "@/components/layout/Heading";
33

44
export interface RoadmapItemProps extends IRoadmapItem {
55

src/components/pages/front/SignupSection.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import Link from "@/components/navigation/Link";
1111
import { backend } from "@/utils/wretch";
1212
import Input from "@/components/controls/Input";
1313
import Form from "@/components/controls/Form";
14-
import Heading from "@/components/layout/heading";
14+
import Heading from "@/components/layout/Heading";
1515

1616

1717
const signupSchema = z.object({

src/pages/404.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import HomeModernIcon from "@heroicons/react/24/solid/HomeModernIcon";
66
import { Page } from "@/types/page";
77
import LinkButton from "@/components/controls/LinkButton";
88
import Button from "@/components/controls/Button";
9-
import Heading from "@/components/layout/heading";
9+
import Heading from "@/components/layout/Heading";
1010

1111
import { makeOgMeta } from "@/utils/opengraph";
1212

src/pages/about.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import Member from "@/components/pages/about/Member";
55
import { Page } from "@/types/page";
66
import projects from "@/assets/state/projects";
77

8-
import Heading from "@/components/layout/heading";
8+
import Heading from "@/components/layout/Heading";
99
import Project from "@/components/pages/about/Project";
1010
import { makeOgMeta } from "@/utils/opengraph";
1111

0 commit comments

Comments
 (0)