1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-04-26 06:45:23 -04:00

refactor "notes" into "posts" (only on the backend)

This commit is contained in:
Jake Jarvis 2024-02-26 12:08:48 -05:00
parent 955cfe421f
commit dbde73c63c
Signed by: jake
SSH Key Fingerprint: SHA256:nCkvAjYA6XaSPUqc4TfbBQTpzr8Xj7ritg/sGInCdkc
19 changed files with 201 additions and 202 deletions

View File

@ -21,7 +21,7 @@ const HitCounter = ({ slug }: HitCounterProps) => {
fetcher
);
// fail somewhat silently, see error boundary in NoteMeta component
// fail somewhat silently, see error boundary in PostMeta component
if (error) {
showBoundary(`${error}`);
return null;

View File

@ -1,2 +0,0 @@
export * from "./NoteMeta";
export { default } from "./NoteMeta";

View File

@ -1,2 +0,0 @@
export * from "./NoteTitle";
export { default } from "./NoteTitle";

View File

@ -1,2 +0,0 @@
export * from "./NotesList";
export { default } from "./NotesList";

View File

@ -2,11 +2,11 @@ import { ErrorBoundary } from "react-error-boundary";
import Link from "../Link";
import Time from "../Time";
import HitCounter from "../HitCounter";
import NoteTitle from "../NoteTitle";
import PostTitle from "../PostTitle";
import { FiCalendar, FiTag, FiEdit, FiEye } from "react-icons/fi";
import { styled, theme } from "../../lib/styles/stitches.config";
import * as config from "../../lib/config";
import type { NoteFrontMatter } from "../../types";
import type { PostFrontMatter } from "../../types";
const Wrapper = styled("div", {
display: "inline-flex",
@ -55,9 +55,9 @@ const Tag = styled("span", {
},
});
export type NoteMetaProps = Pick<NoteFrontMatter, "slug" | "date" | "title" | "htmlTitle" | "tags">;
export type PostMetaProps = Pick<PostFrontMatter, "slug" | "date" | "title" | "htmlTitle" | "tags">;
const NoteMeta = ({ slug, date, title, htmlTitle, tags }: NoteMetaProps) => {
const PostMeta = ({ slug, date, title, htmlTitle, tags }: PostMetaProps) => {
return (
<>
<Wrapper>
@ -116,9 +116,9 @@ const NoteMeta = ({ slug, date, title, htmlTitle, tags }: NoteMetaProps) => {
)}
</Wrapper>
<NoteTitle {...{ slug, title, htmlTitle }} />
<PostTitle {...{ slug, title, htmlTitle }} />
</>
);
};
export default NoteMeta;
export default PostMeta;

View File

@ -0,0 +1,2 @@
export * from "./PostMeta";
export { default } from "./PostMeta";

View File

@ -1,7 +1,7 @@
import Link from "../Link";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
import type { NoteFrontMatter } from "../../types";
import type { PostFrontMatter } from "../../types";
const Title = styled("h1", {
margin: "0.3em 0 0.5em -1px", // misaligned left margin, super nitpicky
@ -18,10 +18,10 @@ const Title = styled("h1", {
},
});
export type NoteTitleProps = Pick<NoteFrontMatter, "slug" | "title" | "htmlTitle"> &
export type PostTitleProps = Pick<PostFrontMatter, "slug" | "title" | "htmlTitle"> &
ComponentPropsWithoutRef<typeof Title>;
const NoteTitle = ({ slug, title, htmlTitle, ...rest }: NoteTitleProps) => {
const PostTitle = ({ slug, title, htmlTitle, ...rest }: PostTitleProps) => {
return (
<Title {...rest}>
<Link
@ -37,4 +37,4 @@ const NoteTitle = ({ slug, title, htmlTitle, ...rest }: NoteTitleProps) => {
);
};
export default NoteTitle;
export default PostTitle;

View File

@ -0,0 +1,2 @@
export * from "./PostTitle";
export { default } from "./PostTitle";

View File

@ -2,7 +2,7 @@ import Link from "../Link";
import Time from "../Time";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ReactElement } from "react";
import type { NotesByYear } from "../../types";
import type { PostsByYear } from "../../types";
const Section = styled("section", {
fontSize: "1.1em",
@ -55,19 +55,19 @@ const PostDate = styled(Time, {
color: theme.colors.medium,
});
export type NotesListProps = {
notesByYear: NotesByYear;
export type PostsListProps = {
postsByYear: PostsByYear;
};
const NotesList = ({ notesByYear }: NotesListProps) => {
const PostsList = ({ postsByYear }: PostsListProps) => {
const sections: ReactElement[] = [];
Object.entries(notesByYear).forEach(([year, notes]) => {
Object.entries(postsByYear).forEach(([year, posts]) => {
sections.push(
<Section key={year}>
<Year>{year}</Year>
<List>
{notes.map(({ slug, date, title, htmlTitle }) => (
{posts.map(({ slug, date, title, htmlTitle }) => (
<Post key={slug}>
<PostDate date={date} format="MMM D" />
<span>
@ -86,10 +86,10 @@ const NotesList = ({ notesByYear }: NotesListProps) => {
);
});
// grouped notes enter this component ordered chronologically -- we want reverse chronological
// grouped posts enter this component ordered chronologically -- we want reverse chronological
const reversed = sections.reverse();
return <>{reversed}</>;
};
export default NotesList;
export default PostsList;

View File

@ -0,0 +1,2 @@
export * from "./PostsList";
export { default } from "./PostsList";

View File

@ -1,5 +1,5 @@
import { Feed } from "feed";
import { getAllNotes } from "./parse-notes";
import { getAllPosts } from "./posts";
import * as config from "../config";
import { meJpg } from "../config/favicons";
import type { GetServerSideProps } from "next";
@ -40,22 +40,22 @@ export const buildFeed = async (
},
});
// add notes separately using their frontmatter
const notes = await getAllNotes();
notes.forEach((note) => {
// add posts separately using their frontmatter
const posts = await getAllPosts();
posts.forEach((post) => {
feed.addItem({
guid: note.permalink,
link: note.permalink,
title: note.title,
description: note.description,
image: note.image && `${baseUrl}${note.image}`,
guid: post.permalink,
link: post.permalink,
title: post.title,
description: post.description,
image: post.image && `${baseUrl}${post.image}`,
author: [
{
name: config.authorName,
link: `${baseUrl}/`,
},
],
date: new Date(note.date),
date: new Date(post.date),
});
});

View File

@ -1,48 +0,0 @@
import { serialize } from "next-mdx-remote/serialize";
import { getNoteData } from "./parse-notes";
import type { NoteWithSource } from "../../types";
// fully parses MDX into JS and returns *everything* about a note
export const compileNote = async (slug: string): Promise<NoteWithSource> => {
const { frontMatter, content } = await getNoteData(slug);
const { remarkGfm, remarkSmartypants, remarkUnwrapImages, rehypeSlug, rehypePrism } = await import(
"./remark-rehype-plugins"
);
const { compiledSource } = await serialize(content, {
parseFrontmatter: false,
mdxOptions: {
remarkPlugins: [
// @ts-ignore
[remarkGfm, { singleTilde: false }],
[
// @ts-ignore
remarkSmartypants,
{
quotes: true,
dashes: "oldschool",
backticks: false,
ellipses: false,
},
],
// @ts-ignore
[remarkUnwrapImages],
],
rehypePlugins: [
// @ts-ignore
[rehypeSlug],
// @ts-ignore
[rehypePrism, { ignoreMissing: true }],
],
},
});
return {
frontMatter,
source: {
compiledSource,
},
};
};

View File

@ -1,90 +0,0 @@
import fs from "fs/promises";
import path from "path";
import glob from "fast-glob";
import pMap from "p-map";
import pMemoize from "p-memoize";
import matter from "gray-matter";
import { formatDate } from "./format-date";
import type { NoteFrontMatter } from "../../types";
export const getNoteSlugs = async (): Promise<string[]> => {
// list all .mdx files in "/notes"
const mdxFiles = await glob("*.mdx", {
cwd: path.join(process.cwd(), "notes"),
dot: false,
});
// strip the .mdx extensions from filenames
const slugs = mdxFiles.map((fileName) => fileName.replace(/\.mdx$/, ""));
return slugs;
};
// returns front matter and/or *raw* markdown contents of a given slug
export const getNoteData = async (
slug: string
): Promise<{
frontMatter: NoteFrontMatter;
content: string;
}> => {
const fullPath = path.join(process.cwd(), "notes", `${slug}.mdx`);
const rawContent = await fs.readFile(fullPath, "utf8");
const { data, content } = matter(rawContent);
const { unified } = await import("unified");
const { remarkParse, remarkSmartypants, remarkRehype, rehypeSanitize, rehypeStringify } = await import(
"./remark-rehype-plugins"
);
// allow *very* limited markdown to be used in post titles
const parseTitle = async (title: string, allowedTags: string[] = []): Promise<string> => {
return String(
await unified()
.use(remarkParse)
.use(remarkSmartypants, {
quotes: true,
dashes: "oldschool",
backticks: false,
ellipses: false,
})
.use(remarkRehype)
.use(rehypeSanitize, { tagNames: allowedTags })
.use(rehypeStringify)
.process(title)
);
};
// process title as both plain and stylized
const [title, htmlTitle] = await Promise.all([
parseTitle(data.title),
parseTitle(data.title, ["code", "em", "strong"]),
]);
// return both the parsed YAML front matter (with a few amendments) and the raw, unparsed markdown content
return {
frontMatter: {
...(data as Partial<NoteFrontMatter>),
// zero markdown title:
title,
htmlTitle,
slug,
permalink: `${process.env.NEXT_PUBLIC_BASE_URL || ""}/notes/${slug}/`,
date: formatDate(data.date), // validate/normalize the date string provided from front matter
},
content,
};
};
// returns the parsed front matter of ALL notes, sorted reverse chronologically
export const getAllNotes = pMemoize(async (): Promise<NoteFrontMatter[]> => {
const slugs = await getNoteSlugs();
// for each slug, query its front matter
const data = await pMap(slugs, async (slug) => (await getNoteData(slug)).frontMatter);
// sort the results by date
data.sort((note1, note2) => (note1.date > note2.date ? -1 : 1));
return data;
});

135
lib/helpers/posts.ts Normal file
View File

@ -0,0 +1,135 @@
import path from "path";
import fs from "fs/promises";
import { serialize } from "next-mdx-remote/serialize";
import glob from "fast-glob";
import pMap from "p-map";
import pMemoize from "p-memoize";
import matter from "gray-matter";
import { formatDate } from "./format-date";
import type { PostFrontMatter, PostWithSource } from "../../types";
// path to directory with .mdx files, relative to project root
export const POSTS_DIR = "./notes";
// returns front matter and the **raw & uncompiled** markdown of a given slug
export const getPostData = async (
slug: string
): Promise<{
frontMatter: PostFrontMatter;
markdown: string;
}> => {
const fullPath = path.join(process.cwd(), POSTS_DIR, `${slug}.mdx`);
const rawContent = await fs.readFile(fullPath, "utf8");
const { data, content } = matter(rawContent);
const { unified } = await import("unified");
const { remarkParse, remarkSmartypants, remarkRehype, rehypeSanitize, rehypeStringify } = await import(
"./remark-rehype-plugins"
);
// allow *very* limited markdown to be used in post titles
const parseTitle = async (title: string, allowedTags: string[] = []): Promise<string> => {
return String(
await unified()
.use(remarkParse)
.use(remarkSmartypants, {
quotes: true,
dashes: "oldschool",
backticks: false,
ellipses: false,
})
.use(remarkRehype)
.use(rehypeSanitize, { tagNames: allowedTags })
.use(rehypeStringify)
.process(title)
);
};
// process title as both plain and stylized
const [title, htmlTitle] = await Promise.all([
parseTitle(data.title),
parseTitle(data.title, ["code", "em", "strong"]),
]);
// return both the parsed YAML front matter (with a few amendments) and the raw, unparsed markdown content
return {
frontMatter: {
...(data as Partial<PostFrontMatter>),
// zero markdown title:
title,
htmlTitle,
slug,
permalink: `${process.env.NEXT_PUBLIC_BASE_URL || ""}/${POSTS_DIR}/${slug}/`,
date: formatDate(data.date), // validate/normalize the date string provided from front matter
},
markdown: content,
};
};
// fully parses MDX into JS and returns *everything* about a post
export const compilePost = async (slug: string): Promise<PostWithSource> => {
const { frontMatter, markdown } = await getPostData(slug);
const { remarkGfm, remarkSmartypants, remarkUnwrapImages, rehypeSlug, rehypePrism } = await import(
"./remark-rehype-plugins"
);
const { compiledSource } = await serialize(markdown, {
parseFrontmatter: false,
mdxOptions: {
remarkPlugins: [
// @ts-ignore
[remarkGfm, { singleTilde: false }],
[
// @ts-ignore
remarkSmartypants,
{
quotes: true,
dashes: "oldschool",
backticks: false,
ellipses: false,
},
],
// @ts-ignore
[remarkUnwrapImages],
],
rehypePlugins: [
// @ts-ignore
[rehypeSlug],
// @ts-ignore
[rehypePrism, { ignoreMissing: true }],
],
},
});
return {
frontMatter,
source: {
compiledSource,
},
};
};
export const getPostSlugs = pMemoize(async (): Promise<string[]> => {
// list all .mdx files in POSTS_DIR
const mdxFiles = await glob("*.mdx", {
cwd: path.join(process.cwd(), POSTS_DIR),
dot: false,
});
// strip the .mdx extensions from filenames
const slugs = mdxFiles.map((fileName) => fileName.replace(/\.mdx$/, ""));
return slugs;
});
// returns the parsed front matter of ALL posts, sorted reverse chronologically
export const getAllPosts = pMemoize(async (): Promise<PostFrontMatter[]> => {
// for each post, query its front matter
const data = await pMap(await getPostSlugs(), async (slug) => (await getPostData(slug)).frontMatter);
// sort the results by date
data.sort((post1, post2) => (post1.date > post2.date ? -1 : 1));
return data;
});

View File

@ -2,16 +2,15 @@ import { InView } from "react-intersection-observer";
import { NextSeo, ArticleJsonLd } from "next-seo";
import { MDXRemote, MDXRemoteProps } from "next-mdx-remote";
import Content from "../../components/Content";
import NoteMeta from "../../components/NoteMeta";
import PostMeta from "../../components/PostMeta";
import Comments from "../../components/Comments";
import * as mdxComponents from "../../lib/helpers/mdx-components";
import { getNoteSlugs } from "../../lib/helpers/parse-notes";
import { compileNote } from "../../lib/helpers/compile-note";
import { getPostSlugs, compilePost } from "../../lib/helpers/posts";
import * as config from "../../lib/config";
import { articleJsonLd } from "../../lib/config/seo";
import { meJpg } from "../../lib/config/favicons";
import type { GetStaticProps, GetStaticPaths, InferGetStaticPropsType } from "next";
import type { NoteWithSource, NoteFrontMatter } from "../../types";
import type { PostWithSource, PostFrontMatter } from "../../types";
const Note = ({ frontMatter, source }: InferGetStaticPropsType<typeof getStaticProps>) => {
return (
@ -51,7 +50,7 @@ const Note = ({ frontMatter, source }: InferGetStaticPropsType<typeof getStaticP
{...articleJsonLd}
/>
<NoteMeta {...frontMatter} />
<PostMeta {...frontMatter} />
<Content>
<MDXRemote {...source} components={{ ...(mdxComponents as MDXRemoteProps["components"]) }} />
@ -70,14 +69,14 @@ const Note = ({ frontMatter, source }: InferGetStaticPropsType<typeof getStaticP
);
};
export const getStaticProps: GetStaticProps<NoteWithSource, Pick<NoteFrontMatter, "slug">> = async ({ params }) => {
export const getStaticProps: GetStaticProps<PostWithSource, Pick<PostFrontMatter, "slug">> = async ({ params }) => {
if (!params?.slug) {
return {
notFound: true,
};
}
const { frontMatter, source } = await compileNote(params.slug);
const { frontMatter, source } = await compilePost(params.slug);
return {
props: {
@ -88,7 +87,10 @@ export const getStaticProps: GetStaticProps<NoteWithSource, Pick<NoteFrontMatter
};
export const getStaticPaths: GetStaticPaths = async () => {
const slugs = await getNoteSlugs();
// get the slug of each .mdx file in /notes
const slugs = await getPostSlugs();
// map slugs into a static paths object required by next.js
const paths = slugs.map((slug) => ({ params: { slug } }));
return {

View File

@ -1,10 +1,10 @@
import { NextSeo } from "next-seo";
import Content from "../../components/Content";
import NotesList from "../../components/NotesList";
import { getAllNotes } from "../../lib/helpers/parse-notes";
import PostsList from "../../components/PostsList";
import { getAllPosts } from "../../lib/helpers/posts";
import { authorName } from "../../lib/config";
import type { GetStaticProps, InferGetStaticPropsType } from "next";
import type { NotesByYear } from "../../types";
import type { PostsByYear } from "../../types";
const Notes = ({ notesByYear }: InferGetStaticPropsType<typeof getStaticProps>) => {
return (
@ -18,18 +18,18 @@ const Notes = ({ notesByYear }: InferGetStaticPropsType<typeof getStaticProps>)
/>
<Content>
<NotesList notesByYear={notesByYear} />
<PostsList postsByYear={notesByYear} />
</Content>
</>
);
};
export const getStaticProps: GetStaticProps<{
notesByYear: NotesByYear;
notesByYear: PostsByYear;
}> = async () => {
// parse the year of each note and group them together
const notes = await getAllNotes();
const notesByYear: NotesByYear = {};
const notes = await getAllPosts();
const notesByYear: PostsByYear = {};
notes.forEach((note) => {
const year = new Date(note.date).getUTCFullYear();

View File

@ -1,5 +1,5 @@
import { SitemapStream, EnumChangefreq } from "sitemap";
import { getAllNotes } from "../lib/helpers/parse-notes";
import { getAllPosts } from "../lib/helpers/posts";
import { siteDomain } from "../lib/config";
import type { GetServerSideProps } from "next";
import type { SitemapItemLoose } from "sitemap";
@ -39,20 +39,20 @@ export const getServerSideProps: GetServerSideProps<Record<string, never>> = asy
{ url: "/zip/" },
];
// push notes separately and use their metadata
const notes = await getAllNotes();
notes.forEach((note) => {
// push posts separately and use their metadata
const posts = await getAllPosts();
posts.forEach((post) => {
pages.push({
url: `/notes/${note.slug}/`,
url: `/notes/${post.slug}/`,
// pull lastMod from front matter date
lastmod: note.date,
lastmod: post.date,
});
});
// set lastmod of /notes/ page to most recent post's date
pages.push({
url: `/notes/`,
lastmod: notes[0].date,
lastmod: posts[0].date,
});
// sort alphabetically by URL

2
types/index.d.ts vendored
View File

@ -1,3 +1,3 @@
export * from "./note";
export * from "./post";
export * from "./project";
export * from "./stats";

View File

@ -1,6 +1,6 @@
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
export type NoteFrontMatter = {
export type PostFrontMatter = {
slug: string;
permalink: string;
date: string;
@ -12,14 +12,14 @@ export type NoteFrontMatter = {
noComments?: boolean;
};
export type NoteWithSource = {
export type PostWithSource = {
// yaml metadata
frontMatter: NoteFrontMatter;
frontMatter: PostFrontMatter;
// the final, compiled JSX by next-mdx-remote; see lib/helpers/parse-notes.ts
// the final, compiled JSX by next-mdx-remote; see lib/helpers/posts.ts
source: Partial<Pick<MDXRemoteSerializeResult<Record<string, never>, Record<string, never>>>>;
};
export type NotesByYear = {
[year: string]: NoteFrontMatter[];
export type PostsByYear = {
[year: string]: PostFrontMatter[];
};