All files / web/src/lib blog.ts

0% Statements 0/188
0% Branches 0/1
0% Functions 0/1
0% Lines 0/188

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))
}