File: //var/www/quadcode.com/src/lib/content/blog.ts
/**
* Local markdown blog loader.
*
* Reads .md files from src/content/blog/, parses YAML frontmatter
* with gray-matter, converts markdown to HTML with marked (GFM),
* and returns fully-shaped IPost objects compatible with all
* existing Svelte components.
*/
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { marked } from 'marked';
import { getAuthor } from './authors';
import type { IPost } from '$type/post';
const CONTENT_DIR = path.resolve('src/content/blog');
marked.use({ gfm: true, breaks: false });
interface BlogFrontmatter {
title: string;
slug: string;
date: string;
modified?: string;
author: string;
tags?: string[];
image?: string;
description?: string;
readAlso?: { slug: string; title: string; color?: string; type?: string }[];
faq?: { question: string; answer: string }[];
views?: number;
expert?: {
name: string;
position: string;
description: string;
slug: string;
linkedin?: string;
avatar?: string;
};
}
function estimateReadingTime(text: string): number {
const words = text.replace(/<[^>]*>/g, '').split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
function generateExcerpt(html: string, maxLength = 160): string {
const text = html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
if (text.length <= maxLength) return text;
return text.slice(0, maxLength).replace(/\s\S*$/, '') + '...';
}
function frontmatterToIPost(fm: BlogFrontmatter, htmlContent: string): IPost {
const author = getAuthor(fm.author);
const readingTime = estimateReadingTime(htmlContent);
const excerpt = generateExcerpt(htmlContent);
const imageUrl = fm.image ?? '';
return {
id: hashSlug(fm.slug),
imageData: {
thumbnail: imageUrl,
medium: imageUrl,
mediumLarge: imageUrl,
large: imageUrl,
},
date: fm.date,
date_gmt: fm.date,
guid: { rendered: '' },
modified: fm.modified ?? fm.date,
modified_gmt: fm.modified ?? fm.date,
slug: fm.slug,
status: 'publish',
type: 'post',
link: `/blog/${fm.slug}`,
title: { rendered: fm.title },
content: { rendered: htmlContent, protected: false },
excerpt: { rendered: excerpt, protected: false },
expertData: fm.expert
? {
name: fm.expert.name,
position: fm.expert.position,
description: fm.expert.description,
slug: fm.expert.slug,
linkedin: fm.expert.linkedin ?? '',
company: '',
avatar: {
thumbnail: fm.expert.avatar ?? '',
medium: fm.expert.avatar ?? '',
mediumLarge: fm.expert.avatar ?? '',
large: fm.expert.avatar ?? '',
},
}
: null,
author: 0,
authorData: {
name: author.name,
description: author.description,
position: author.position,
slug: author.slug,
totalPosts: author.totalPosts,
avatar: author.avatar,
linkedin: author.linkedin,
},
featured_media: 0,
comment_status: 'closed',
ping_status: 'closed',
sticky: false,
template: '',
format: 'standard',
meta: { footnotes: '' },
categories: [],
tags: [],
tagsData: fm.tags ?? [],
views: String(fm.views ?? 0),
acf: {
views: String(fm.views ?? 0),
faq: fm.faq ?? [],
},
readAlso: (fm.readAlso ?? []).map((r) => ({
title: r.title,
slug: r.slug,
color: r.color ?? null,
type: r.type ?? 'post',
})),
estReadingTime: String(readingTime),
yoast_head: '',
yoast_head_json: {
title: fm.title,
robots: {
index: 'index',
follow: 'follow',
'max-snippet': 'max-snippet:-1',
'max-image-preview': 'max-image-preview:large',
'max-video-preview': 'max-video-preview:-1',
},
og_locale: 'en_US',
og_type: 'article',
og_title: fm.title,
og_description: fm.description ?? excerpt,
og_url: `/blog/${fm.slug}`,
og_site_name: 'Quadcode',
article_published_time: fm.date,
article_modified_time: fm.modified ?? fm.date,
og_image: [
{
width: 1200,
height: 630,
url: imageUrl,
type: 'image/jpeg',
},
],
author: author.name,
twitter_card: 'summary_large_image',
twitter_misc: {
'Written by': author.name,
'Est. reading time': `${readingTime} min`,
},
},
} as IPost;
}
function hashSlug(slug: string): number {
let hash = 0;
for (let i = 0; i < slug.length; i++) {
const char = slug.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0;
}
return Math.abs(hash) + 100000;
}
function parseMarkdownFile(filePath: string): { frontmatter: BlogFrontmatter; html: string } | null {
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const { data, content } = matter(raw);
const html = marked.parse(content) as string;
return { frontmatter: data as BlogFrontmatter, html };
} catch {
return null;
}
}
export function getLocalPost(slug: string): IPost | null {
const filePath = path.join(CONTENT_DIR, `${slug}.md`);
if (!fs.existsSync(filePath)) return null;
const parsed = parseMarkdownFile(filePath);
if (!parsed) return null;
return frontmatterToIPost(parsed.frontmatter, parsed.html);
}
export function getAllLocalPosts(): IPost[] {
if (!fs.existsSync(CONTENT_DIR)) return [];
const files = fs.readdirSync(CONTENT_DIR).filter((f) => f.endsWith('.md'));
const posts: IPost[] = [];
for (const file of files) {
const filePath = path.join(CONTENT_DIR, file);
const parsed = parseMarkdownFile(filePath);
if (parsed) {
posts.push(frontmatterToIPost(parsed.frontmatter, parsed.html));
}
}
return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
export function getAllLocalSlugs(): string[] {
if (!fs.existsSync(CONTENT_DIR)) return [];
return fs
.readdirSync(CONTENT_DIR)
.filter((f) => f.endsWith('.md'))
.map((f) => f.replace(/\.md$/, ''));
}