-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmdx.ts
More file actions
111 lines (100 loc) · 3.38 KB
/
Copy pathmdx.ts
File metadata and controls
111 lines (100 loc) · 3.38 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
import remarkGfm from "remark-gfm";
import type { MDXRemoteProps } from "next-mdx-remote/rsc";
import { routing } from "@/i18n/routing";
export type LearnCategory = "processing" | "brewing";
/**
* Options passed to <MDXRemote> for our trusted, repo-local articles.
*
* - `remarkGfm` enables GitHub-flavored markdown (notably tables).
* - `blockJS: false` is REQUIRED: next-mdx-remote v6 defaults to stripping all
* `{expression}` JSX attributes as a security measure, which silently drops
* props like `<BrewTimer totalSeconds={195} stages={[...]} />`. Our content is
* authored in this repo (never user-supplied), so this is safe. We leave
* `blockDangerousJS` at its default (true) for defense-in-depth.
*/
export const mdxRenderOptions: NonNullable<MDXRemoteProps["options"]> = {
mdxOptions: { remarkPlugins: [remarkGfm] },
blockJS: false,
};
export interface ArticleFrontmatter {
title: string;
description: string;
summary?: string;
readingTimeMinutes?: number;
related?: string[];
}
export interface ArticleSummary {
category: LearnCategory;
slug: string;
frontmatter: ArticleFrontmatter;
}
export interface ArticleSource {
category: LearnCategory;
slug: string;
frontmatter: ArticleFrontmatter;
content: string;
}
const CONTENT_DIR = path.join(process.cwd(), "src", "content");
// Articles live under `src/content/<locale>/<category>/<slug>.mdx`. English is
// the source language; a missing localized file falls back to English so the
// reader never hits a 404 while a translation is pending.
function categoryDir(category: LearnCategory, locale: string): string {
return path.join(CONTENT_DIR, locale, category);
}
function readDirSafe(dir: string): string[] {
try {
return fs.readdirSync(dir, { withFileTypes: true })
.filter((d) => d.isFile() && d.name.endsWith(".mdx"))
.map((d) => d.name);
} catch {
return [];
}
}
/**
* Slugs are locale-invariant, so enumeration always reads the English tree.
* This keeps `generateStaticParams` stable and guarantees both locales render
* every article even before its translation exists.
*/
export function getArticleSlugs(category: LearnCategory): string[] {
return readDirSafe(categoryDir(category, routing.defaultLocale)).map((f) =>
f.replace(/\.mdx$/, ""),
);
}
export function getArticle(
category: LearnCategory,
slug: string,
locale: string = routing.defaultLocale,
): ArticleSource | null {
const localized = path.join(categoryDir(category, locale), `${slug}.mdx`);
const fallback = path.join(
categoryDir(category, routing.defaultLocale),
`${slug}.mdx`,
);
const filePath = fs.existsSync(localized) ? localized : fallback;
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf8");
const parsed = matter(raw);
const frontmatter = parsed.data as ArticleFrontmatter;
return {
category,
slug,
frontmatter,
content: parsed.content,
};
}
export function getAllArticles(
category: LearnCategory,
locale: string = routing.defaultLocale,
): ArticleSummary[] {
return getArticleSlugs(category)
.map((slug) => {
const article = getArticle(category, slug, locale);
return article
? { category, slug, frontmatter: article.frontmatter }
: null;
})
.filter((a): a is ArticleSummary => a !== null);
}