Skip to content

Commit 79baefb

Browse files
authored
Merge pull request #83 from cmpadden/agent/comark-content-module-migration
Migrate markdown rendering to Comark content module
2 parents ad1b47c + 0b9b434 commit 79baefb

16 files changed

Lines changed: 607 additions & 1676 deletions

File tree

‎components/section/BlogPosts.vue‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,7 @@ function externalSite(article) {
4848
/>
4949
</template>
5050
<div class="line-clamp-3 text-sm text-gray-100">
51-
{{
52-
article.description ||
53-
article.meta?.excerpt?.children?.[0]?.value
54-
}}
51+
{{ article.description || article.excerpt }}
5552
</div>
5653
</div>
5754
</NuxtLink>

‎content.config.ts‎

Lines changed: 0 additions & 31 deletions
This file was deleted.

‎modules/content.ts‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { readdirSync, readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import {
4+
addServerScanDir,
5+
addServerTemplate,
6+
createResolver,
7+
defineNuxtModule,
8+
} from "@nuxt/kit";
9+
import { parse } from "comark";
10+
import highlight from "comark/plugins/highlight";
11+
import matter from "gray-matter";
12+
import { z } from "zod";
13+
import type { BlogArticle } from "./content/runtime/types/blog-article";
14+
15+
const frontmatterSchema = z.object({
16+
title: z.string().optional(),
17+
description: z.string().optional(),
18+
date: z.coerce.date(),
19+
tags: z.array(z.string()),
20+
categories: z.array(z.string()),
21+
cover_image: z.string().optional(),
22+
img: z.string().optional(),
23+
external_url: z.string().url().optional(),
24+
external_site: z.string().optional(),
25+
canonical_url: z.string().url().optional(),
26+
draft: z.boolean().optional(),
27+
});
28+
29+
function slugFromFilePath(filePath: string) {
30+
return filePath.split("/").pop()?.replace(/\.md$/, "") || "";
31+
}
32+
33+
function excerptFromBody(body: string) {
34+
const summary = body.split(/<!--\s*more\s*-->/i)[0] || body;
35+
36+
return summary
37+
.replace(/```[\s\S]*?```/g, "")
38+
.replace(/<!--[\s\S]*?-->/g, "")
39+
.replace(/<[^>]+>/g, "")
40+
.replace(/!\[[^\]]*]\([^)]+\)/g, "")
41+
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
42+
.replace(/[#>*_`~-]/g, "")
43+
.replace(/\s+/g, " ")
44+
.trim();
45+
}
46+
47+
async function parseArticle(
48+
filePath: string,
49+
rawMarkdown: string,
50+
): Promise<BlogArticle> {
51+
const slug = slugFromFilePath(filePath);
52+
const { data, content } = matter(rawMarkdown);
53+
const frontmatter = frontmatterSchema.parse(data);
54+
const tree = await parse(content.trim(), {
55+
plugins: [highlight()],
56+
});
57+
58+
return {
59+
...frontmatter,
60+
_id: `content:blog:${slug}`,
61+
slug,
62+
path: `/blog/${slug}`,
63+
tree,
64+
excerpt: excerptFromBody(content),
65+
date: frontmatter.date.toISOString(),
66+
};
67+
}
68+
69+
async function loadArticles(rootDir: string) {
70+
const blogContentDir = join(rootDir, "content", "blog");
71+
const articles = await Promise.all(
72+
readdirSync(blogContentDir)
73+
.filter((fileName) => fileName.endsWith(".md"))
74+
.map((fileName) => {
75+
const filePath = join(blogContentDir, fileName);
76+
return parseArticle(filePath, readFileSync(filePath, "utf8"));
77+
}),
78+
);
79+
80+
return articles.sort(
81+
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
82+
);
83+
}
84+
85+
export default defineNuxtModule({
86+
meta: {
87+
name: "content",
88+
},
89+
setup(_, nuxt) {
90+
const resolver = createResolver(import.meta.url);
91+
92+
addServerTemplate({
93+
filename: "#content/articles",
94+
async getContents() {
95+
const articles = await loadArticles(nuxt.options.rootDir);
96+
97+
return `const articles = ${JSON.stringify(articles, null, 2)}
98+
99+
const articleSummaries = articles.map(({ tree, ...article }) => article)
100+
101+
export function getAllBlogArticles(options = {}) {
102+
if (options.includeDrafts) {
103+
return articles
104+
}
105+
106+
return articles.filter((article) => !article.draft)
107+
}
108+
109+
export function getAllBlogArticleSummaries(options = {}) {
110+
if (options.includeDrafts) {
111+
return articleSummaries
112+
}
113+
114+
return articleSummaries.filter((article) => !article.draft)
115+
}
116+
117+
export function getBlogArticleBySlug(slug) {
118+
return articles.find((article) => article.slug === slug)
119+
}
120+
`;
121+
},
122+
});
123+
124+
addServerScanDir(resolver.resolve("./content/runtime/server"));
125+
},
126+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { getBlogArticleBySlug } from "#content/articles";
2+
3+
export default defineEventHandler((event) => {
4+
const slug = getRouterParam(event, "slug");
5+
const article = slug ? getBlogArticleBySlug(slug) : undefined;
6+
7+
if (!article) {
8+
throw createError({
9+
statusCode: 404,
10+
statusMessage: "Article not found",
11+
});
12+
}
13+
14+
return article;
15+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { getAllBlogArticleSummaries } from "#content/articles";
2+
3+
export default defineEventHandler(() => {
4+
return getAllBlogArticleSummaries();
5+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export type BlogArticle = {
2+
_id: string;
3+
slug: string;
4+
path: string;
5+
tree: unknown;
6+
excerpt: string;
7+
title?: string;
8+
description?: string;
9+
date: string;
10+
tags: string[];
11+
categories: string[];
12+
cover_image?: string;
13+
img?: string;
14+
external_url?: string;
15+
external_site?: string;
16+
canonical_url?: string;
17+
draft?: boolean;
18+
};
19+
20+
export type BlogArticleSummary = Omit<BlogArticle, "tree">;

‎nuxt.config.ts‎

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { join, dirname } from "path";
44
// https://nuxt.com/docs/api/configuration/nuxt-config
55
export default defineNuxtConfig({
66
components: true,
7-
modules: ["@nuxt/content", "@nuxtjs/tailwindcss", "nuxt-headlessui"],
7+
modules: [
8+
"./modules/content",
9+
"@comark/nuxt",
10+
"@nuxtjs/tailwindcss",
11+
"nuxt-headlessui",
12+
],
813

914
app: {
1015
head: {
@@ -38,31 +43,6 @@ export default defineNuxtConfig({
3843
prefix: "Headless",
3944
},
4045

41-
content: {
42-
build: {
43-
markdown: {
44-
// Disable highlighting until interoperability with by the Tailwind Typography plugin is sorted
45-
// highlight: false
46-
highlight: {
47-
theme: "github-dark",
48-
preload: [
49-
"bash",
50-
"js",
51-
"json",
52-
"lisp",
53-
"lua",
54-
"python",
55-
"shell",
56-
"ts",
57-
"vue",
58-
],
59-
},
60-
},
61-
},
62-
// https://github.com/nuxt/content/issues/3249#issuecomment-2778749735
63-
experimental: { nativeSqlite: true },
64-
},
65-
6646
nitro: {
6747
// render server-side routes as static content
6848
prerender: {

‎package.json‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,19 @@
1010
"format": "prettier --parser vue components/ pages/ layouts/ --write"
1111
},
1212
"devDependencies": {
13-
"@nuxt/content": "^3.8.2",
13+
"@comark/nuxt": "^0.5.1",
1414
"@nuxtjs/tailwindcss": "^6.14.0",
1515
"@tailwindcss/typography": "^0.5.16",
16-
"better-sqlite3": "^12.4.1",
1716
"feed": "^4.2.2",
17+
"gray-matter": "^4.0.3",
1818
"nuxt": "^4.5.1",
1919
"nuxt-headlessui": "^1.2.1",
2020
"prettier": "^3.5.3",
2121
"prettier-plugin-tailwindcss": "^0.6.11",
22+
"shiki": "^4.3.1",
2223
"sitemap": "^8.0.0",
23-
"webpack": "^5.99.9"
24+
"webpack": "^5.99.9",
25+
"zod": "^4.4.3"
2426
},
2527
"dependencies": {
2628
"chords.ts": "^0.1.0",

‎pages/blog/[...slug].vue‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
<script setup lang="ts">
22
const route = useRoute();
33
4-
const { data: page } = await useAsyncData(route.path, () => {
5-
return queryCollection("content").path(route.path).first();
4+
const slug = computed(() => {
5+
const value = route.params.slug;
6+
return Array.isArray(value) ? value.join("/") : value;
67
});
78
9+
const { data: page } = await useFetch(() => `/api/articles/${slug.value}`);
10+
811
const isExternal = computed(() => Boolean((page.value as any)?.external_url));
912
const isDraft = computed(() => Boolean((page.value as any)?.draft));
1013
const title = computed(() => pageTitle(page.value?.title));
@@ -106,7 +109,6 @@ function externalSite(p: any) {
106109
>
107110
<!-- title -->
108111
<div class="flex">
109-
<!-- https://content.nuxt.com/components/content-slot -->
110112
<template v-if="page.cover_image || page.img">
111113
<img
112114
class="mr-4 h-16 border-2 border-black"
@@ -184,20 +186,18 @@ function externalSite(p: any) {
184186

185187
<!--
186188
- Remove maximum width of prose content: https://github.com/tailwindlabs/tailwindcss-typography#overriding-max-width
187-
- Use prose-pre:bg-white to work with @nuxt/content syntax highlighting, otherwise background-color defaults to `.prose:where(pre)`
188189
-->
189190
<article
190-
class="prose max-w-[1024px] text-gray-300 prose-h2:mt-8 prose-a:font-bold prose-a:text-orange-400 prose-a:no-underline hover:prose-a:text-orange-200 prose-blockquote:text-gray-400 prose-strong:text-gray-100 prose-code:text-white prose-pre:bg-black/70 prose-li:my-0"
191+
class="prose max-w-[1024px] text-gray-300 prose-headings:text-white prose-h2:mt-8 prose-h2:border-b prose-h2:border-white/10 prose-h2:pb-2 prose-h3:text-orange-100 prose-a:font-bold prose-a:text-orange-400 prose-a:no-underline hover:prose-a:text-orange-200 prose-blockquote:text-gray-400 prose-strong:text-gray-100 prose-code:text-white prose-pre:bg-black/70 prose-li:my-0"
191192
>
192-
<ContentRenderer v-if="page" :value="page" />
193+
<ComarkRenderer v-if="page?.tree" :tree="page.tree" />
193194
</article>
194195
</div>
195196
</template>
196197

197198
<style>
198199
/*
199-
* For some reason min-height is set to 1rem in ProsePre.vue of @nuxt/content, override this for more suitable code block line height
200-
* https://github.com/nuxt/content/blob/056da889a28c2f8cfe622a2848a652324b4c32c8/src/runtime/components/Prose/ProsePre.vue#L43
200+
* Keep code block line boxes compact when syntax highlighting wraps lines.
201201
*/
202202
pre code .line {
203203
min-height: 0.25rem !important;

0 commit comments

Comments
 (0)