refactor: integrate content-collections for notes (#2609)

This commit is contained in:
2026-06-30 16:29:50 -04:00
committed by GitHub
parent c2bf0c610e
commit b6cf79ff12
18 changed files with 1450 additions and 1379 deletions
+3
View File
@@ -38,3 +38,6 @@ yarn.lock
# next-agents-md
.next-docs/
# content-collections
.content-collections/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 128 KiB

+8 -1
View File
@@ -7,6 +7,7 @@ import { FadeTransition } from "@/components/page-transition";
import domainstackIcon from "./icons/domainstack.png";
import snoozleIcon from "./icons/snoozle.png";
import sofaIcon from "./icons/sofa.png";
import stanzaIcon from "./icons/stanza.png";
import uiIcon from "./icons/ui.png";
import versioneerIcon from "./icons/versioneer.png";
@@ -24,6 +25,12 @@ const projects: readonly Project[] = [
tagline: "Domain intelligence made easy",
icon: domainstackIcon,
},
{
name: "Stanza",
url: "https://stanza.tools",
tagline: "shadcn/ui for infrastructure",
icon: stanzaIcon,
},
{
name: "Sofa",
url: "https://sofa.watch",
@@ -45,7 +52,7 @@ const projects: readonly Project[] = [
{
name: "jarvis-ui",
url: "https://ui.jarv.is",
tagline: "An intentionally minimal React component library",
tagline: "Intentionally random React component library",
icon: uiIcon,
},
] as const;
+1 -1
View File
@@ -195,7 +195,7 @@
@layer base {
* {
@apply outline-ring/50;
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
+24 -24
View File
@@ -14,7 +14,7 @@ import { ViewCounter } from "@/components/view-counter";
import authorConfig from "@/lib/config/author";
import siteConfig from "@/lib/config/site";
import { createMetadata } from "@/lib/metadata";
import { getFrontMatter, getSlugs, POSTS_DIR } from "@/lib/posts";
import { getFrontMatter, getPost, getSlugs, POSTS_DIR } from "@/lib/posts";
import { size as ogImageSize } from "./opengraph-image";
@@ -54,10 +54,10 @@ export const generateMetadata = async ({
const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
const { slug } = await params;
const frontmatter = await getFrontMatter(slug);
if (!frontmatter) notFound();
const d = new Date(frontmatter.date);
const post = await getPost(slug);
if (!post) notFound();
const d = new Date(post.date);
const formattedDates = {
dateISO: d.toISOString(),
dateTitle: d.toLocaleString("en-US", {
@@ -83,18 +83,18 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
item={{
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: frontmatter?.title,
description: frontmatter?.description,
url: frontmatter?.permalink,
headline: post.title,
description: post.description,
url: post.permalink,
image: {
"@type": "ImageObject",
contentUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/${POSTS_DIR}/${frontmatter?.slug}/opengraph-image`,
contentUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/${POSTS_DIR}/${post.slug}/opengraph-image`,
width: `${ogImageSize.width}`,
height: `${ogImageSize.height}`,
},
keywords: frontmatter?.tags?.join(", "),
datePublished: frontmatter?.date,
dateModified: frontmatter?.date,
keywords: post.tags?.join(", "),
datePublished: post.date,
dateModified: post.date,
inLanguage: process.env.NEXT_PUBLIC_SITE_LOCALE,
license: `https://spdx.org/licenses/${siteConfig.license}.html`,
author: {
@@ -106,7 +106,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
<div className="text-foreground/70 flex flex-wrap items-center gap-x-4 gap-y-2.5 text-[13px] tracking-wide">
<Link
href={`/${POSTS_DIR}/${frontmatter?.slug}`}
href={`/${POSTS_DIR}/${post.slug}`}
className={
"flex flex-nowrap items-center gap-1.5 whitespace-nowrap text-inherit hover:no-underline"
}
@@ -121,10 +121,10 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
</time>
</Link>
{frontmatter?.tags && (
{post.tags && (
<div className="flex flex-wrap items-center gap-1.5">
<IconTag className="inline size-3.5 shrink-0" aria-hidden="true" />
{frontmatter?.tags.map((tag) => (
{post.tags.map((tag) => (
<span
key={tag}
title={tag}
@@ -137,8 +137,8 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
)}
<Link
href={`https://github.com/${process.env.NEXT_PUBLIC_GITHUB_REPO}/blob/main/${POSTS_DIR}/${frontmatter?.slug}/index.mdx`}
title={`Edit "${frontmatter?.title}" on GitHub`}
href={`https://github.com/${process.env.NEXT_PUBLIC_GITHUB_REPO}/blob/main/${POSTS_DIR}/${post.slug}/index.mdx`}
title={`Edit "${post.title}" on GitHub`}
className={
"flex flex-nowrap items-center gap-1.5 whitespace-nowrap text-inherit hover:no-underline"
}
@@ -148,25 +148,25 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
</Link>
<Link
href={`/${POSTS_DIR}/${frontmatter?.slug}#comments`}
href={`/${POSTS_DIR}/${post.slug}#comments`}
className="flex flex-nowrap items-center gap-1.5 whitespace-nowrap text-inherit hover:no-underline"
>
<IconMessages className="inline size-3.5 shrink-0" aria-hidden="true" />
<CommentCount slug={`${POSTS_DIR}/${frontmatter?.slug}`} />
<CommentCount slug={`${POSTS_DIR}/${post.slug}`} />
</Link>
<div className="flex min-w-14 flex-nowrap items-center gap-1.5 whitespace-nowrap">
<IconEye className="inline size-3.5 shrink-0" aria-hidden="true" />
<ViewCounter slug={`${POSTS_DIR}/${frontmatter?.slug}`} />
<ViewCounter slug={`${POSTS_DIR}/${post.slug}`} />
</div>
</div>
<ViewTransition name={`note-title-${frontmatter.slug}`} share="text-morph" default="none">
<ViewTransition name={`note-title-${post.slug}`} share="text-morph" default="none">
<h1 className="my-5 text-2xl font-medium tracking-tight">
<Link
href={`/${POSTS_DIR}/${frontmatter.slug}`}
href={`/${POSTS_DIR}/${post.slug}`}
dangerouslySetInnerHTML={{
__html: frontmatter.htmlTitle || frontmatter.title,
__html: post.htmlTitle || post.title,
}}
className="text-foreground hover:no-underline"
/>
@@ -179,7 +179,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
<section id="comments" className="isolate my-8 w-full border-t-2 pt-8">
<div className="mx-auto w-full max-w-3xl space-y-6">
{frontmatter?.noComments ? (
{post.noComments ? (
<div className="bg-muted/40 flex justify-center rounded-lg px-6 py-12">
<p className="text-center text-lg font-medium">Comments are closed.</p>
</div>
@@ -192,7 +192,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
}
>
<ViewTransition enter="slide-up" default="none">
<Comments slug={`${POSTS_DIR}/${frontmatter.slug}`} />
<Comments slug={`${POSTS_DIR}/${post.slug}`} />
</ViewTransition>
</Suspense>
)}
+1 -1
View File
@@ -9,7 +9,7 @@ import { getFrontMatter } from "@/lib/posts";
// here to exclude it.
const excludedRoutes = [
// homepage is already included manually
"./",
"./(home)",
// other excluded pages
// "./license",
// "./privacy",
+107
View File
@@ -0,0 +1,107 @@
import "dotenv/config";
import { defineCollection, defineConfig } from "@content-collections/core";
import { decode } from "html-entities";
import { unified } from "unified";
import { z } from "zod";
import { rehypeSanitize, rehypeStringify } from "@/lib/rehype";
import {
remarkFrontmatter,
remarkMdx,
remarkParse,
remarkRehype,
remarkSmartypants,
remarkStripMdxImportsExports,
} from "@/lib/remark";
const POSTS_DIR = "notes" as const;
const parseableDate = z.string().refine((value) => !Number.isNaN(Date.parse(value)), {
message: "Invalid date string",
});
const titleToHtml = async (title: string): Promise<string> => {
return unified()
.use(remarkParse)
.use(remarkSmartypants)
.use(remarkRehype)
.use(rehypeSanitize, {
tagNames: ["code", "em", "strong"],
})
.use(rehypeStringify)
.process(title)
.then((result) => result.toString().trim());
};
const contentToFeedHtml = async (content: string): Promise<string> => {
const parsedContent = await unified()
.use(remarkParse)
.use(remarkMdx)
.use(remarkStripMdxImportsExports)
.use(remarkFrontmatter)
.use(remarkSmartypants)
.use(remarkRehype)
.use(rehypeSanitize, {
tagNames: [
"p",
"a",
"em",
"strong",
"code",
"pre",
"blockquote",
"del",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"ul",
"ol",
"li",
"hr",
],
})
.use(rehypeStringify)
.process(content);
return parsedContent
.toString()
.replaceAll("/* prettier-ignore */", "")
.replaceAll("<p></p>", "")
.trim();
};
const posts = defineCollection({
name: "posts",
directory: POSTS_DIR,
include: "*/index.mdx",
schema: z.object({
content: z.string(),
date: parseableDate,
title: z.string(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
image: z.string().optional(),
noComments: z.boolean().optional(),
}),
transform: async (post) => {
const slug = post["_meta"].directory.replace(`${POSTS_DIR}/`, "");
const htmlTitle = await titleToHtml(post.title);
const title = decode(htmlTitle.replace(/<[^>]*>/g, ""));
return {
...post,
feedHtml: await contentToFeedHtml(post.content),
title,
htmlTitle,
slug,
date: new Date(post.date).toISOString(),
permalink: `${process.env.NEXT_PUBLIC_BASE_URL || ""}/${POSTS_DIR}/${slug}`,
};
},
});
export default defineConfig({
content: [posts],
});
+3 -2
View File
@@ -10,13 +10,15 @@ import { getContent, getFrontMatter } from "@/lib/posts";
* @see https://github.com/jpmonette/feed#example
*/
export const buildFeed = async (): Promise<Feed> => {
const frontmatter = await getFrontMatter();
const feed = new Feed({
id: `${process.env.NEXT_PUBLIC_BASE_URL}`,
link: `${process.env.NEXT_PUBLIC_BASE_URL}`,
title: siteConfig.name,
description: siteConfig.description,
copyright: `https://spdx.org/licenses/${siteConfig.license}.html`,
updated: new Date(),
updated: frontmatter[0] ? new Date(frontmatter[0].date) : undefined,
image: `${process.env.NEXT_PUBLIC_BASE_URL}${ogImage.src}`,
feedLinks: {
rss: `${process.env.NEXT_PUBLIC_BASE_URL}/feed.xml`,
@@ -30,7 +32,6 @@ export const buildFeed = async (): Promise<Feed> => {
});
// parse posts into feed items
const frontmatter = await getFrontMatter();
const posts: FeedItem[] = await Promise.all(
frontmatter.map(async (post) => ({
guid: post.permalink,
+34 -117
View File
@@ -1,19 +1,4 @@
import fs from "node:fs/promises";
import path from "node:path";
import glob from "fast-glob";
import { decode } from "html-entities";
import { unified } from "unified";
import { rehypeSanitize, rehypeStringify } from "@/lib/rehype";
import {
remarkFrontmatter,
remarkMdx,
remarkParse,
remarkRehype,
remarkSmartypants,
remarkStripMdxImportsExports,
} from "@/lib/remark";
import { allPosts } from "content-collections";
export type FrontMatter = {
slug: string;
@@ -27,137 +12,69 @@ export type FrontMatter = {
noComments?: boolean;
};
export type Post = (typeof allPosts)[number];
/** Path to directory with .mdx files, relative to project root. */
export const POSTS_DIR = "notes" as const;
/** Use filesystem to get a simple list of all post slugs */
const sortPosts = (posts: Post[]): Post[] => {
return posts.toSorted(
(post1, post2) => new Date(post2.date).getTime() - new Date(post1.date).getTime(),
);
};
/** Use generated content collections data to get all post slugs. */
export const getSlugs = async (): Promise<string[]> => {
"use cache";
// list all .mdx files in POSTS_DIR
const mdxFiles = await glob("*/index.mdx", {
cwd: path.join(process.cwd(), POSTS_DIR),
dot: false,
});
return allPosts.map((post) => post.slug);
};
// strip the .mdx extensions from filenames
const slugs = mdxFiles.map((fileName) => fileName.replace(/\/index\.mdx$/, ""));
export const getPost = async (slug: string): Promise<Post | undefined> => {
"use cache";
return slugs;
return allPosts.find((post) => post.slug === slug);
};
export const getFrontMatter: {
/**
* Parses and returns the front matter of ALL posts, sorted reverse chronologically
* Returns the front matter of ALL posts, sorted reverse chronologically.
*/
(): Promise<FrontMatter[]>;
/**
* Parses and returns the front matter of a given slug, or undefined if the slug does not exist
* Returns the front matter of a given slug, or undefined if the slug does not exist.
*/
(slug: string): Promise<FrontMatter | undefined>;
} = (async (slug?: string) => {
"use cache";
const toFrontMatter = (post: Post): FrontMatter => ({
slug: post.slug,
permalink: post.permalink,
date: post.date,
title: post.title,
htmlTitle: post.htmlTitle,
description: post.description,
tags: post.tags,
image: post.image,
noComments: post.noComments,
});
if (typeof slug === "string") {
try {
const { frontmatter } = await import(`../${POSTS_DIR}/${slug}/index.mdx`);
// process markdown title to html...
const htmlTitle = await unified()
.use(remarkParse)
.use(remarkSmartypants)
.use(remarkRehype)
.use(rehypeSanitize, {
// allow *very* limited markdown to be used in post titles
tagNames: ["code", "em", "strong"],
})
.use(rehypeStringify)
.process(frontmatter.title)
.then((result) => result.toString().trim());
// ...and then (sketchily) remove said html for a plaintext version:
// https://css-tricks.com/snippets/javascript/strip-html-tags-in-javascript/
const title = decode(htmlTitle.replace(/<[^>]*>/g, ""));
return {
...(frontmatter as Partial<FrontMatter>),
// plain title without html or markdown syntax:
title,
// stylized title with limited html tags:
htmlTitle,
slug,
// validate/normalize the date string provided from front matter
date: new Date(frontmatter.date).toISOString(),
permalink: `${process.env.NEXT_PUBLIC_BASE_URL}/${POSTS_DIR}/${slug}`,
} as FrontMatter;
} catch (error) {
console.error(`Failed to load front matter for post with slug "${slug}":`, error);
return undefined;
}
const matchedPost = allPosts.find((candidate) => candidate.slug === slug);
return matchedPost ? toFrontMatter(matchedPost) : undefined;
}
if (!slug) {
// concurrently fetch the front matter of each post
const slugs = await getSlugs();
const allPosts = await Promise.all(slugs.map(getFrontMatter));
// filter out any undefined entries from failed imports
const posts = allPosts.filter((p): p is FrontMatter => !!p);
// sort the results reverse chronologically and return
return posts.sort(
(post1, post2) => new Date(post2.date).getTime() - new Date(post1.date).getTime(),
);
return sortPosts(allPosts).map(toFrontMatter);
}
throw new Error("getFrontMatter() called with invalid argument.");
}) as typeof getFrontMatter;
/** Returns the content of a post with very limited processing to include in RSS feeds */
/** Returns the sanitized HTML content of a post for RSS feeds. */
export const getContent = async (slug: string): Promise<string | undefined> => {
"use cache";
try {
const content = await unified()
.use(remarkParse)
.use(remarkMdx)
.use(remarkStripMdxImportsExports)
.use(remarkFrontmatter)
.use(remarkSmartypants)
.use(remarkRehype)
.use(rehypeSanitize, {
tagNames: [
"p",
"a",
"em",
"strong",
"code",
"pre",
"blockquote",
"del",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"ul",
"ol",
"li",
"hr",
],
})
.use(rehypeStringify)
.process(await fs.readFile(path.join(process.cwd(), `${POSTS_DIR}/${slug}/index.mdx`)));
// convert the parsed content to a string with "safe" HTML
return content
.toString()
.replaceAll("/* prettier-ignore */", "")
.replaceAll("<p></p>", "")
.trim();
} catch (error) {
console.error(`Failed to load/parse content for post with slug "${slug}":`, error);
return undefined;
}
return allPosts.find((post) => post.slug === slug)?.feedHtml;
};
+2 -1
View File
@@ -124,7 +124,6 @@ const nextConfig = {
],
} satisfies NextConfig;
// my own macgyvered version of next-compose-plugins (RIP)
const nextPlugins: Array<
(config: NextConfig) => NextConfig | [(config: NextConfig) => NextConfig, any]
> = [
@@ -150,6 +149,8 @@ const nextPlugins: Array<
],
},
}),
// content-collections must be last; see https://www.content-collections.dev/docs/adapter/next#installation
require("@content-collections/next").withContentCollections,
];
export default (): NextConfig =>
+24 -21
View File
@@ -17,24 +17,24 @@
"lint:fix": "oxlint --fix",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check",
"check-types": "tsc --noEmit",
"check-types": "content-collections build && tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@base-ui/react": "^1.6.0",
"@fontsource/inter": "^5.2.8",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "16.2.7",
"@number-flow/react": "^0.6.0",
"@next/mdx": "16.2.9",
"@number-flow/react": "^0.6.1",
"@octokit/graphql": "^9.0.3",
"@octokit/graphql-schema": "^15.26.1",
"@tabler/icons-react": "^3.44.0",
"@vercel/analytics": "^2.0.1",
"@vercel/functions": "^3.6.1",
"@vercel/functions": "^3.7.4",
"@vercel/speed-insights": "^2.0.0",
"better-auth": "^1.6.14",
"better-auth": "^1.6.22",
"cheerio": "^1.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -43,14 +43,14 @@
"fast-glob": "^3.3.3",
"feed": "^5.2.1",
"html-entities": "^2.6.0",
"next": "16.2.7",
"next": "16.2.9",
"next-themes": "^0.4.6",
"pg": "^8.21.0",
"pg": "^8.22.0",
"react": "19.2.7",
"react-activity-calendar": "^3.2.0",
"react-compare-slider": "^4.0.0",
"react-dom": "19.2.7",
"react-lite-youtube-embed": "~3.5.1",
"react-lite-youtube-embed": "~3.6.0",
"react-markdown": "^10.1.0",
"react-schemaorg": "^2.0.1",
"react-timeago": "^8.3.0",
@@ -72,36 +72,39 @@
"remark-smartypants": "^3.0.2",
"remark-strip-mdx-imports-exports": "^1.0.1",
"server-only": "0.0.1",
"shadcn": "^4.10.0",
"shiki": "^4.2.0",
"shadcn": "^4.12.0",
"shiki": "^4.3.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"unified": "^11.0.5",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.0",
"@tailwindcss/typography": "^0.5.19",
"@types/mdx": "^2.0.13",
"@types/node": "^25.9.1",
"@content-collections/cli": "^0.1.9",
"@content-collections/core": "^0.15.2",
"@content-collections/next": "^0.2.11",
"@tailwindcss/postcss": "^4.3.2",
"@tailwindcss/typography": "^0.5.20",
"@types/mdx": "^2.0.14",
"@types/node": "^26.0.1",
"@types/pg": "^8.20.0",
"@types/react": "19.2.16",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"oxfmt": "^0.53.0",
"oxlint": "^1.68.0",
"postcss": "^8.5.15",
"oxfmt": "^0.56.0",
"oxlint": "^1.71.0",
"postcss": "^8.5.16",
"schema-dts": "^2.0.0",
"tailwindcss": "^4.3.0",
"tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0",
"typescript": "6.0.3"
},
"engines": {
"node": ">=24.x"
},
"packageManager": "pnpm@11.5.1",
"packageManager": "pnpm@11.9.0",
"cacheDirectories": [
"node_modules",
".next/cache"
+1238 -1206
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -3,7 +3,5 @@ allowBuilds:
msw: true
sharp: true
unrs-resolver: true
minimumReleaseAgeExclude:
- react-tweet@3.3.1
overrides:
kysely: ^0.28.17
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"extends": ["config:recommended", "security:minimumReleaseAgeNpm"],
"labels": ["📦 dependencies"],
"prConcurrentLimit": 0,
"prHourlyLimit": 0,
+4 -2
View File
@@ -19,7 +19,8 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"content-collections": ["./.content-collections/generated"]
}
},
"include": [
@@ -27,7 +28,8 @@
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
".next/dev/types/**/*.ts",
".content-collections/generated/**/*.ts"
],
"exclude": ["node_modules"]
}