Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Build & Deploy / 🧪 QA (push) Has started running
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Fixes an issue where TOC links wouldn't scroll due to ID generation mismatches on MDX headers containing formatted text or German umlauts.
201 lines
5.8 KiB
TypeScript
201 lines
5.8 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import matter from 'gray-matter';
|
|
import { mapSlugToFileSlug } from './slugs';
|
|
import { config } from '@/lib/config';
|
|
|
|
export interface PostFrontmatter {
|
|
title: string;
|
|
date: string;
|
|
excerpt?: string;
|
|
featuredImage?: string | null;
|
|
category?: string;
|
|
locale: string;
|
|
public?: boolean;
|
|
}
|
|
|
|
export interface PostMdx {
|
|
slug: string;
|
|
frontmatter: PostFrontmatter;
|
|
content: string;
|
|
}
|
|
|
|
export function isPostVisible(post: { frontmatter: { date: string; public?: boolean } }) {
|
|
// If explicitly marked as not public, hide in production
|
|
if (post.frontmatter.public === false && config.isProduction) {
|
|
return false;
|
|
}
|
|
|
|
const postDate = new Date(post.frontmatter.date);
|
|
const now = new Date();
|
|
return !(postDate > now && config.isProduction);
|
|
}
|
|
|
|
export async function getPostBySlug(slug: string, locale: string): Promise<PostMdx | null> {
|
|
// Map translated slug to file slug
|
|
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
|
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
|
const filePath = path.join(postsDir, `${fileSlug}.mdx`);
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
return null;
|
|
}
|
|
|
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
|
const { data, content } = matter(fileContent);
|
|
|
|
const postInfo = {
|
|
slug: fileSlug,
|
|
frontmatter: data as PostFrontmatter,
|
|
content,
|
|
};
|
|
|
|
if (!isPostVisible(postInfo)) {
|
|
return null;
|
|
}
|
|
|
|
return postInfo;
|
|
}
|
|
|
|
export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
|
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
|
if (!fs.existsSync(postsDir)) return [];
|
|
|
|
const files = fs.readdirSync(postsDir);
|
|
const posts = files
|
|
.filter((file) => file.endsWith('.mdx'))
|
|
.map((file) => {
|
|
const filePath = path.join(postsDir, file);
|
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
|
const { data, content } = matter(fileContent);
|
|
return {
|
|
slug: file.replace(/\.mdx$/, ''),
|
|
frontmatter: data as PostFrontmatter,
|
|
content,
|
|
};
|
|
})
|
|
.filter(isPostVisible)
|
|
.sort(
|
|
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
|
);
|
|
|
|
return posts;
|
|
}
|
|
|
|
export async function getAllPostsMetadata(locale: string): Promise<Partial<PostMdx>[]> {
|
|
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
|
if (!fs.existsSync(postsDir)) return [];
|
|
|
|
const files = fs.readdirSync(postsDir);
|
|
return files
|
|
.filter((file) => file.endsWith('.mdx'))
|
|
.map((file) => {
|
|
const filePath = path.join(postsDir, file);
|
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
|
const { data } = matter(fileContent);
|
|
return {
|
|
slug: file.replace(/\.mdx$/, ''),
|
|
frontmatter: data as PostFrontmatter,
|
|
};
|
|
})
|
|
.filter(isPostVisible)
|
|
.sort(
|
|
(a, b) =>
|
|
new Date(b.frontmatter.date as string).getTime() -
|
|
new Date(a.frontmatter.date as string).getTime(),
|
|
);
|
|
}
|
|
|
|
export async function getAdjacentPosts(
|
|
slug: string,
|
|
locale: string,
|
|
): Promise<{
|
|
prev: PostMdx | null;
|
|
next: PostMdx | null;
|
|
isPrevRandom?: boolean;
|
|
isNextRandom?: boolean;
|
|
}> {
|
|
const posts = await getAllPosts(locale);
|
|
const currentIndex = posts.findIndex((post) => post.slug === slug);
|
|
|
|
if (currentIndex === -1) {
|
|
return { prev: null, next: null };
|
|
}
|
|
|
|
// Posts are sorted by date descending (newest first)
|
|
// So "next" post (newer) is at index - 1
|
|
// And "previous" post (older) is at index + 1
|
|
let next = currentIndex > 0 ? posts[currentIndex - 1] : null;
|
|
let prev = currentIndex < posts.length - 1 ? posts[currentIndex + 1] : null;
|
|
|
|
let isNextRandom = false;
|
|
let isPrevRandom = false;
|
|
|
|
const getRandomPost = (excludeSlugs: string[]) => {
|
|
const available = posts.filter((p) => !excludeSlugs.includes(p.slug));
|
|
if (available.length === 0) return null;
|
|
return available[Math.floor(Math.random() * available.length)];
|
|
};
|
|
|
|
// If there's no next post (we are at the newest post), show a random post instead
|
|
if (!next && posts.length > 2) {
|
|
next = getRandomPost([slug, prev?.slug].filter(Boolean) as string[]);
|
|
isNextRandom = true;
|
|
}
|
|
|
|
// If there's no previous post (we are at the oldest post), show a random post instead
|
|
if (!prev && posts.length > 2) {
|
|
prev = getRandomPost([slug, next?.slug].filter(Boolean) as string[]);
|
|
isPrevRandom = true;
|
|
}
|
|
|
|
return { prev, next, isPrevRandom, isNextRandom };
|
|
}
|
|
|
|
export function getReadingTime(content: string): number {
|
|
const wordsPerMinute = 200;
|
|
const noOfWords = content.split(/\s/g).length;
|
|
const minutes = noOfWords / wordsPerMinute;
|
|
return Math.ceil(minutes);
|
|
}
|
|
|
|
export function generateHeadingId(text: string): string {
|
|
let id = text.toLowerCase();
|
|
id = id.replace(/ä/g, 'ae');
|
|
id = id.replace(/ö/g, 'oe');
|
|
id = id.replace(/ü/g, 'ue');
|
|
id = id.replace(/ß/g, 'ss');
|
|
|
|
id = id.replace(/[*_`]/g, '');
|
|
id = id.replace(/[^\w\s-]/g, '');
|
|
id = id
|
|
.replace(/\s+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-+|-+$/g, '');
|
|
|
|
return id || 'heading';
|
|
}
|
|
|
|
export function getTextContent(node: any): string {
|
|
if (typeof node === 'string') return node;
|
|
if (typeof node === 'number') return node.toString();
|
|
if (Array.isArray(node)) return node.map(getTextContent).join('');
|
|
if (node && typeof node === 'object' && node.props && node.props.children) {
|
|
return getTextContent(node.props.children);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
export function getHeadings(content: string): { id: string; text: string; level: number }[] {
|
|
const headingLines = content.split('\n').filter((line) => line.match(/^#{2,3}\s/));
|
|
|
|
return headingLines.map((line) => {
|
|
const level = line.match(/^#+/)?.[0].length || 0;
|
|
const rawText = line.replace(/^#+\s/, '').trim();
|
|
const cleanText = rawText.replace(/[*_`]/g, '');
|
|
const id = generateHeadingId(cleanText);
|
|
|
|
return { id, text: cleanText, level };
|
|
});
|
|
}
|