Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Extract blog infrastructure into library #7

Draft
wants to merge 7 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/blog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./types";
export { usePostData } from "./src/data";
29 changes: 29 additions & 0 deletions lib/blog/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import webpack from "webpack";

/**
*
* @param {import("next").NextConfig} config
* @returns
*/
export function withBlog(config = {}) {
const originalWebpack = config.webpack;
/**
* @param {string} phase
* @param {{ defaultConfig: import("next").NextConfig }} options
*/
// @ts-ignore
return async (phase, options) => {
config.webpack = (config, context) => {
originalWebpack?.(config, context);
if (!context.isServer) {
config.plugins.push(
// Intentionally verbose and explicit to make it hard
// for this to match user code.
new webpack.IgnorePlugin({ resourceRegExp: /\/__server-only\// }),
);
}
return config;
};
return config;
};
}
116 changes: 116 additions & 0 deletions lib/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
import { PageLayoutProps, PostDataStore, Post, MdxOptions } from "./types";
import { usePostWatcher } from "./src/watcher-client";
import { PostDataProvider } from "./src/data";
import { FrontMatter } from "./src/internal-types";
import { GetStaticPaths, GetStaticProps } from "next";
import { DEFAULT_SLUG_PARTS } from "@alexharri/blog/src/constants";

export interface CreatePageOptions {
components: Record<string, React.ComponentType<any> | ((props: any) => React.ReactNode)>;
Layout: React.ComponentType<PageLayoutProps>;
}

interface PageProps {
source: MDXRemoteSerializeResult;
version: string;
slug: string;
data: PostDataStore;
postsPath: string;
}

export function createPostPage(options: CreatePageOptions) {
function Page(props: PageProps) {
const source = usePostWatcher(props);

// Ensure scope is present and 'FrontMatter' has title
let scope = ((source.scope as unknown) ?? {}) as FrontMatter;
if (!scope.title) scope = { ...scope, title: "Untitled post" };

const post: Post = {
title: scope.title,
slug: props.slug,
description: scope.description || "",
publishedAt: scope.publishedAt || "",
updatedAt: scope.updatedAt || "",
image: scope.image || "",
firstCodeSnippet: null,
};

return (
<options.Layout post={post}>
<PostDataProvider data={props.data}>
<MDXRemote {...(source as any)} components={options.components} />
</PostDataProvider>
</options.Layout>
);
}
return Page;
}

type Params = Partial<{ [key: string]: string }>;

interface CreateGetStaticPropsOptions {
mdxOptions?: MdxOptions;
slug?: string;
/**
* @default ["slug"]
*/
slugParts?: string[]; // TBD: Can this be inferred from call stack?
/**
* Path to directory containing your posts as '.md' or '.mdx', relative to 'process.cwd()'
*
* @default "posts/"
*/
postsPath?: string;
}

export const createGetStaticProps =
(options: CreateGetStaticPropsOptions = {}): GetStaticProps<PageProps, Params> =>
async (ctx) => {
const blogPageUtils = await import("./src/__server-only/blogPageUtils");
let slug = options.slug;
if (!slug) {
const slugParts = options.slugParts || DEFAULT_SLUG_PARTS;
slug = slugParts
.map((key) => {
const value = ctx.params?.[key];
if (!value) throw new Error(`Missing context parameter '${key}'`);
return value;
})
.join("/");
}
return blogPageUtils.getPostProps(slug, options);
};

interface GetStaticPathsOptions {
/**
* If set to true, this page only renders posts that have not been published
* and returns 404 for published posts.
*
* @default false
*/
drafts?: boolean;
/**
* @default ["slug"]
*/
slugParts?: string[];
/**
* Path to directory containing your posts as '.md' or '.mdx', relative to 'process.cwd()'
*
* @default "posts/"
*/
postsPath?: string;
}

export const createGetStaticPaths =
(options: GetStaticPathsOptions = {}): GetStaticPaths<Params> =>
async () => {
const blogPageUtils = await import("./src/__server-only/blogPageUtils");
const { slugParts = DEFAULT_SLUG_PARTS, postsPath = "posts/" } = options;
const type = options.drafts ? "draft" : "published";
return {
paths: blogPageUtils.getPostPaths({ type, slugParts, postsPath }),
fallback: false,
};
};
1 change: 1 addition & 0 deletions lib/blog/posts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { getPosts, getPostPaths } from "./src/__server-only/blogPageUtils.js";
168 changes: 168 additions & 0 deletions lib/blog/src/__server-only/blogPageUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { findMdFiles } from "../md.js";
import { Post, PostDataStore } from "../../types.js";
import { FrontMatter, GetPostOptions, GetPostsOptions } from "../internal-types.js";
import { DEFAULT_POSTS_PATH } from "../constants.js";

export const getPosts = (options: GetPostsOptions = {}) => {
const { type = "published", postsPath = DEFAULT_POSTS_PATH } = options;

const posts: Post[] = [];

for (const fileName of findMdFiles(postsPath)) {
const filePath = path.join(postsPath, fileName);
const fileContent = fs.readFileSync(filePath, "utf-8");

const { data } = matter(fileContent);

const {
title,
description = "",
publishedAt = "",
updatedAt = "",
image = "",
include = {},
} = data as FrontMatter;

let firstCodeSnippet: Post["firstCodeSnippet"] = null;
if (include.firstCodeSnippet) {
const lines = fileContent.split("\n");
const startLineIndex = lines.findIndex((line) => line.startsWith("```"));
let endLineIndex = -1;
for (let i = startLineIndex + 1; i < lines.length; i++) {
if (lines[i].startsWith("```")) {
endLineIndex = i;
break;
}
}
if (endLineIndex !== -1) {
const language = lines[startLineIndex].substring(3);
const text = lines.slice(startLineIndex + 1, endLineIndex).join("\n") + "\n";
firstCodeSnippet = { text, language };
}
}

const slug = fileName.replace(/\.mdx?$/, "");

const includePost = publishedAt ? type === "published" : type === "draft";

if (includePost) {
posts.push({ title, description, slug, publishedAt, updatedAt, image, firstCodeSnippet });
}
}

return posts.sort((a, b) => {
return b.publishedAt.localeCompare(a.publishedAt);
});
};

function customPageExists(slug: string, isDraftPost: boolean) {
const extensions = [".js", ".ts", ".jsx", ".tsx"];
const roots = ["./src/pages", "./pages/"];
const cwd = process.cwd();
for (const root of roots) {
for (const ext of extensions) {
const pagePath = path.resolve(
cwd,
// TBD: /blog/ shouldn't be hardcoded
`${root}/blog/${isDraftPost ? "draft/" : ""}${slug}${ext}`,
);
if (fs.existsSync(pagePath)) return true;
}
}
return false;
}

interface GetPostPathsOptions {
type: "published" | "draft";
slugParts: string[];
postsPath: string;
}

export function getPostPaths(options: GetPostPathsOptions) {
const draft = options.type === "draft";
const postsPath = path.resolve(process.cwd(), options.postsPath);

const paths = findMdFiles(postsPath)
.filter((filePath) => {
const fileContent = fs.readFileSync(path.resolve(postsPath, filePath));
const isDraftPost = !matter(fileContent).data.publishedAt;
if (draft) return isDraftPost;
return !isDraftPost;
})
.map((path) => path.replace(/\.mdx?$/, ""))
.filter((slug) => !customPageExists(slug, draft))
.map((slug) => {
const slugParts = slug.split("/");
const expectedLen = options.slugParts.length;
if (slugParts.length !== expectedLen) {
throw new Error(
`Expected number of slug parts to equal ${expectedLen}. Got ${slugParts.length}`,
);
}
const params: Partial<Record<string, string>> = {};
for (const [i, key] of options.slugParts.entries()) {
params[key] = slugParts[i];
}
return { params };
});

return [...paths];
}

function getPostData(slug: string): PostDataStore {
const dataDir = path.resolve(process.cwd(), `./public/data/${slug}`);

if (!fs.existsSync(dataDir)) return {};

const out: PostDataStore = {};
for (const fileName of fs.readdirSync(dataDir)) {
const filePath = path.resolve(dataDir, fileName);
const json = JSON.parse(fs.readFileSync(filePath, "utf-8"));
const dataSlug = fileName.split(".")[0];
out[dataSlug] = json;
}
return out;
}

export const getPostProps = async (slug: string, options: GetPostOptions = {}) => {
const { postsPath = DEFAULT_POSTS_PATH } = options;
let filePath = path.join(postsPath, `${slug}.mdx`);
if (!fs.existsSync(filePath)) {
filePath = path.join(postsPath, `${slug}.md`);
}

if (!fs.existsSync(filePath)) {
return { notFound: true as const };
}

const fileContent = fs.readFileSync(filePath);

const { content, data: scope } = matter(fileContent);

let { mdxOptions } = options;
if (typeof mdxOptions === "function") mdxOptions = await mdxOptions();

const serialize = (await import("next-mdx-remote/serialize")).serialize;
const source = await serialize(content, { scope, mdxOptions });

let version = "0";

const versionFilePath = path.resolve(postsPath, "./.version", slug);

if (fs.existsSync(versionFilePath)) {
version = fs.readFileSync(versionFilePath, "utf-8");
}

const data = getPostData(slug);

return { props: { source, slug, data, version, postsPath } };
};

export function getSlugFromFilePath(filePath: string) {
const fileName = filePath.split("/").at(-1)!;
const slug = fileName.split(".")[0];
return slug;
}
6 changes: 6 additions & 0 deletions lib/blog/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import path from "path";

export const POSTS_PATH = path.resolve(process.cwd(), "./posts");

export const DEFAULT_SLUG_PARTS = ["slug"];
export const DEFAULT_POSTS_PATH = "posts/";
3 changes: 2 additions & 1 deletion src/data/DataProvider.tsx → lib/blog/src/data.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createContext, useContext } from "react";
import { PostDataStore } from "../types/Post";
import { PostDataStore } from "../types";

const PostDataContext = createContext<PostDataStore>({});

Expand All @@ -12,6 +12,7 @@ export const PostDataProvider: React.FC<{ children: React.ReactNode; data: PostD
export function usePostData<T = unknown>(slug: string): T {
const dataStore = useContext(PostDataContext);
const data = dataStore[slug];
// TBD: Improve error message. How do you add data for the post?
if (!data) throw new Error(`No data with slug '${slug}'`);
return data as T;
}
35 changes: 35 additions & 0 deletions lib/blog/src/internal-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { MdxOptions } from "../types";

export interface FrontMatter {
title: string;
description?: string;
image?: string;
publishedAt?: string;
updatedAt?: string;
include?: {
firstCodeSnippet?: boolean;
};
}

export interface GetPostsOptions {
/**
* @default "published"
*/
type?: "published" | "draft";
/**
* Path to directory containing your posts as '.md' or '.mdx', relative to 'process.cwd()'
*
* @default "posts/"
*/
postsPath?: string;
}

export interface GetPostOptions {
mdxOptions?: MdxOptions;
/**
* Path to directory containing your posts as '.md' or '.mdx', relative to 'process.cwd()'
*
* @default "posts/"
*/
postsPath?: string;
}
9 changes: 1 addition & 8 deletions src/utils/mdxUtils.ts → lib/blog/src/md.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import fs from "fs";
import path from "path";

export const POSTS_PATH = path.resolve(process.cwd(), "./posts");
export const SNIPPETS_PATH = path.resolve(process.cwd(), "./snippets");

function findMdFiles(rootPath: string) {
export function findMdFiles(rootPath: string) {
const out: string[] = [];
const stack: string[] = [];

Expand All @@ -27,7 +24,3 @@ function findMdFiles(rootPath: string) {
dfs(rootPath);
return out;
}

export const postFileNames = findMdFiles(POSTS_PATH);

export const snippetFileNames = findMdFiles(SNIPPETS_PATH);
Loading