Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 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 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | import fs from 'fs' import path from 'path' import matter from 'gray-matter' import { remark } from 'remark' import remarkGfm from 'remark-gfm' import remarkHtml from 'remark-html' const postsDirectory = path.join(process.cwd(), 'content', 'blog') const heroHtmlDirectory = path.join(postsDirectory, 'hero-html') export interface BlogEmbed { id: string description: string } export interface BlogPost { slug: string title: string description: string author: string publishedAt: string updatedAt: string tags: string[] featured: boolean heroPrompt?: string heroImage?: string heroAspectRatio?: string heroCrop?: string heroType?: string heroStoryId?: string heroComponentId?: string content: string html: string embeds: BlogEmbed[] } export interface BlogPostMetadata extends Omit<BlogPost, 'content' | 'html'> { excerpt?: string heroImageUrl?: string heroHtml?: string } /** * Get all blog post slugs (filenames without .md extension) */ export function getAllPostSlugs(): string[] { try { const fileNames = fs.readdirSync(postsDirectory) return fileNames .filter((fileName) => fileName.endsWith('.md')) .map((fileName) => fileName.replace(/\.md$/, '')) } catch { // Directory doesn't exist yet or is empty return [] } } /** * Get metadata for all posts (without full content) */ export async function getAllPostsMetadata(): Promise<BlogPostMetadata[]> { const publicDir = path.join(process.cwd(), 'public') const slugs = getAllPostSlugs() const posts = await Promise.all( slugs.map(async (slug) => { const post = await getPostBySlug(slug) const { content, html, ...metadata } = post // Create excerpt from first prose paragraph (skip images, headings, HRs, HTML blocks) const firstPara = content.split('\n\n').find((p) => { const t = p.trim() return t && !t.startsWith('![') && !t.startsWith('#') && t !== '---' && !t.includes('<') }) ?? '' // Strip markdown bold/italic markers for clean display const stripped = firstPara.replace(/^#+\s+/, '').replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1') const excerpt = `${stripped.substring(0, 200)}...` // Resolve hero image URL let heroImageUrl: string | undefined if (metadata.heroImage) { heroImageUrl = metadata.heroImage } else if (fs.existsSync(path.join(publicDir, 'blog', `${slug}.png`))) { heroImageUrl = `/blog/${slug}.png` } // Read hero HTML for html-type heroes (raw HTML from file) let heroHtml: string | undefined if (metadata.heroType === 'html') { const htmlPath = path.join(heroHtmlDirectory, `${slug}.html`) if (fs.existsSync(htmlPath)) { heroHtml = fs.readFileSync(htmlPath, 'utf8') } } // heroComponentId is passed through from frontmatter for component-type heroes return { ...metadata, excerpt, heroImageUrl, heroHtml } }) ) // Sort by published date, newest first return posts.sort((a, b) => { return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime() }) } /** * Get a single post by slug with full content */ export async function getPostBySlug(slug: string): Promise<BlogPost> { const fullPath = path.join(postsDirectory, `${slug}.md`) const fileContents = fs.readFileSync(fullPath, 'utf8') // Parse frontmatter const { data, content } = matter(fileContents) // Convert markdown to HTML const processedContent = await remark() .use(remarkGfm) // GitHub Flavored Markdown (tables, strikethrough, etc.) .use(remarkHtml, { sanitize: false }) .process(content) let html = processedContent.toString() // Strip leading H1 if it duplicates the frontmatter title const title = data.title || 'Untitled' const leadingH1 = html.match(/^\s*<h1>(.*?)<\/h1>\s*/i) if (leadingH1) { // Compare text content (strip HTML tags from the H1 content) const h1Text = leadingH1[1].replace(/<[^>]*>/g, '').trim() if (h1Text === title.trim()) { html = html.slice(leadingH1[0].length) } } // Strip leading image if the post has a hero (the page template renders the hero separately) const hasHero = !!(data.heroPrompt || data.heroImage || data.heroType || data.heroComponentId) if (hasHero) { const leadingImg = html.match(/^\s*<p>\s*<img[^>]*src="([^"]*)"[^>]*>\s*<\/p>\s*/i) if (leadingImg) { html = html.slice(leadingImg[0].length) } } // Parse <!-- EMBED: id "description" --> markers from raw markdown const embedPattern = /<!--\s*EMBED:\s*([\w-]+)\s+"([^"]+)"\s*-->/g const embeds: BlogEmbed[] = [] let embedMatch while ((embedMatch = embedPattern.exec(content)) !== null) { embeds.push({ id: embedMatch[1], description: embedMatch[2] }) } return { slug, title: data.title || 'Untitled', description: data.description || '', author: data.author || 'Anonymous', publishedAt: data.publishedAt || new Date().toISOString(), updatedAt: data.updatedAt || data.publishedAt || new Date().toISOString(), tags: data.tags || [], featured: data.featured || false, heroPrompt: data.heroPrompt || undefined, heroImage: data.heroImage || undefined, heroAspectRatio: data.heroAspectRatio || undefined, heroCrop: data.heroCrop || undefined, heroType: data.heroType || undefined, heroStoryId: data.heroStoryId || undefined, heroComponentId: data.heroComponentId || undefined, content, html, embeds, } } /** * Get featured posts for homepage */ export async function getFeaturedPosts(): Promise<BlogPostMetadata[]> { const allPosts = await getAllPostsMetadata() return allPosts.filter((post) => post.featured).slice(0, 3) } /** * Get posts by tag */ export async function getPostsByTag(tag: string): Promise<BlogPostMetadata[]> { const allPosts = await getAllPostsMetadata() return allPosts.filter((post) => post.tags.includes(tag)) } |