---
title: "Building My Personal Website: Technical Highlights"
pubDate: 2025-12-07T00:00:00.000Z
updatedDate: 2025-12-07T00:00:00.000Z
tags:
  - Web Development
  - AI
image: /images/blog/building-my-personal-website/featured.png
---

This website is built with [Astro](https://astro.build/) using server-side rendering (SSR), styled with Tailwind CSS, and features an AI chat interface powered by Google's Gemini API. The source code is available on [GitHub](https://github.com/FridljDa/FridljDa.github.io).

In this post, I'll walk through three interesting technical features I implemented:

1. **AI Chat Interface** - A streaming chat interface that provides context-aware responses about my background and blog posts
2. **Context Generation & "View as Markdown"** - A unified system that generates markdown representations of pages for both AI context and user viewing
3. **Resume PDF Sync** - The resume PDF is synced from a dedicated CV_management repo that owns rendering; this site only hosts the artifact

![Technical Highlights: Building a Personal Website](/images/blog/building-my-personal-website/infographic.png)

## 1. AI Chat Interface Implementation

The chat interface allows visitors to ask questions about my experience, skills, publications, and blog posts.

### Architecture Overview

The chat system consists of two main parts:

- **Client-side React component** (`src/components/Chat.tsx`) - Handles UI, user input, and streaming response display
- **Server-side API route** (`src/pages/api/chat.ts`) - Processes requests, generates context, and streams responses from Gemini

### Streaming Responses

One of the key features is streaming responses, which provides a more responsive user experience. The client uses the `ReadableStream` API to process chunks as they arrive from the server:

```tsx
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim() || isLoading) return;

    const userMessage = { role: 'user' as const, content: input };
    setMessages((prev) => [...prev, userMessage]);
    setInput('');
    setIsLoading(true);

    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ messages: [...messages, userMessage] }),
      });

      if (!response.ok || !response.body) throw new Error('Network error');

      // Initialize Stream Reader
      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let aiResponseText = '';
      setMessages((prev) => [...prev, { role: 'ai', content: '' }]);

      // Read Loop - process chunks as they arrive
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value, { stream: true });
        aiResponseText += chunk;

        // Update UI incrementally
        setMessages((prev) => {
          const newHistory = [...prev];
          const lastMsg = newHistory[newHistory.length - 1];
          if (lastMsg.role === 'ai') {
            lastMsg.content = aiResponseText;
          }
          return newHistory;
        });
      }
    } catch (error) {
      // ... error handling ...
    } finally {
      setIsLoading(false);
    }
  };
```

The client creates a `ReadableStream` reader, decodes each chunk using `TextDecoder`, and updates the UI incrementally as text arrives. This creates a smooth, ChatGPT-like experience where responses appear word-by-word.

### Server-Side Streaming with Gemini

On the server, the API route constructs a comprehensive context from the website content and streams the response from Gemini:

```typescript
    // Generate streaming response from Gemini
    const result = await chat.sendMessageStream(lastUserMessage);

    // Create ReadableStream for HTTP Response
    const stream = new ReadableStream({
      async start(controller) {
        const encoder = new TextEncoder();
        try {
          for await (const chunk of result.stream) {
            const chunkText = chunk.text();
            if (chunkText) {
              controller.enqueue(encoder.encode(chunkText));
            }
          }
          controller.close();
        } catch (err) {
          // ... error handling ...
          controller.close();
        }
      },
    });

    return new Response(stream, {
      headers: {
        'Content-Type': 'text/plain; charset=utf-8',
        'Transfer-Encoding': 'chunked',
        'X-Content-Type-Options': 'nosniff',
      },
    });
```

The server uses Gemini's `sendMessageStream()` method, which returns an async iterable. Each chunk is encoded and enqueued to the `ReadableStream`, which the client consumes in real-time.

### Context Injection

The chat system is context-aware, meaning it has access to all the content on the website. The context is generated from two sources:

1. **Home page content** - Biography, experience, publications, and blog post summaries
2. **Full blog post content** - Complete markdown from all blog posts

```typescript
    // Gather all markdown content for context
    const blogPosts = await getCollection('blog');
    const homePageMarkdown = generateHomePageMarkdown(blogPosts);
    
    // Get markdown content from all blog posts
    const blogPostsMarkdown = await Promise.all(
      blogPosts.map(async (post: CollectionEntry<'blog'>) => {
        const markdown = await getBlogPostRawMarkdown(post);
        return `## Blog Post: ${post.data.title}\n\n${markdown}\n\n---\n\n`;
      })
    );
    
    const allBlogPostsMarkdown = blogPostsMarkdown.join('\n');

    // Construct system prompt with all context
    const systemInstruction = `
      You are a professional AI assistant representing Daniel Fridljand...
      // ... persona and rules definition ...
      
      === HOME PAGE CONTENT (includes CV/Resume information) ===
      ${homePageMarkdown}
      
      === BLOG POSTS (FULL CONTENT) ===
      ${allBlogPostsMarkdown}
    `;
```

This context is injected as a system instruction when starting the chat session, ensuring the AI has access to all relevant information while maintaining strict boundaries to prevent hallucination.

### Mobile-Responsive Design

The chat component automatically minimizes on mobile devices (screens smaller than 768px) and can be toggled between minimized and expanded states:

```tsx
  // Detect mobile on initial load and set minimized by default
  useEffect(() => {
    if (window.innerWidth < 768) {
      setIsMinimized(true);
    }
    // ... resize handler setup ...
  }, []);
```

When minimized, it appears as a floating button in the bottom-right corner, expanding to a full chat interface when clicked.

## 2. Context Generation & "View as Markdown" Feature

A key design decision was to create a unified system for generating markdown representations of pages. This serves two purposes:

1. **AI Context** - Provides structured markdown content to the chat system
2. **User Viewing** - Allows users to view any page as markdown via "View as Markdown" buttons

### Markdown Generation Utilities

The core markdown generation logic lives in `src/utils/markdown-generator.ts`. This file contains functions to convert different data structures into markdown:

- `biographyToMarkdown()` - Converts biography data to markdown
- `experienceToMarkdown()` - Converts experience entries to markdown
- `publicationsToMarkdown()` - Converts publications to markdown
- `blogPostToMarkdownSummary()` - Creates summaries of blog posts
- `generateHomePageMarkdown()` - Combines all sections into a single document
- `getBlogPostRawMarkdown()` - Reads and reconstructs full blog post markdown

The `generateHomePageMarkdown()` function combines all home page content:

```typescript
export function generateHomePageMarkdown(blogPosts: CollectionEntry<'blog'>[]): string {
  let markdown = `# Daniel Fridljand - Personal Website\n\n`;
  markdown += `This page contains my professional biography, experience, and blog posts.\n\n`;
  markdown += `---\n\n`;
  markdown += biographyToMarkdown();
  markdown += `---\n\n`;
  markdown += experienceToMarkdown();
  markdown += `---\n\n`;
  markdown += publicationsToMarkdown();
  markdown += `---\n\n`;
  markdown += `# Blog Posts\n\n`;
  
  const sortedPosts = [...blogPosts].sort(
    (a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()
  );
  
  sortedPosts.forEach(post => {
    markdown += blogPostToMarkdownSummary(post);
  });
  
  return markdown;
}
```

For blog posts, `getBlogPostRawMarkdown()` reads the original MDX file and reconstructs it with frontmatter:

```typescript
export async function getBlogPostRawMarkdown(post: CollectionEntry<'blog'>): Promise<string> {
  // Reconstruct frontmatter from post data
  let markdown = `---\n`;
  markdown += `title: "${post.data.title}"\n`;
  markdown += `pubDate: ${post.data.pubDate.toISOString()}\n`;
  // ... other frontmatter fields (updatedDate, tags, math, image) ...
  markdown += `---\n\n`;
  
  // Read original MDX file from filesystem
  const fs = await import('fs/promises');
  const path = await import('path');
  try {
    const filePath = path.join(process.cwd(), 'src/content/blog', `${post.id}.mdx`);
    const fileContent = await fs.readFile(filePath, 'utf-8');
    
    // Extract content after frontmatter
    const frontmatterEnd = fileContent.indexOf('---', 3);
    if (frontmatterEnd !== -1) {
      const content = fileContent.substring(frontmatterEnd + 3).trimStart();
      markdown += content;
    } else {
      markdown += fileContent;
    }
  } catch (error) {
    // ... fallback error handling ...
  }
  
  return markdown;
}
```

This function reads the original MDX file from the filesystem, extracts the content after the frontmatter, and reconstructs a complete markdown document with the frontmatter included.

### API Routes for Markdown

Two API routes serve markdown to users:

1. **`/index.md`** - Serves the home page as markdown
2. **`/post/[slug].md`** - Serves individual blog posts as markdown

Both routes use the same utility functions used by the chat system:

```typescript
export const GET: APIRoute = async () => {
  try {
    const blogPosts = await getCollection('blog');
    const markdown = generateHomePageMarkdown(blogPosts);
    
    return new Response(markdown, {
      headers: {
        'Content-Type': 'text/markdown; charset=utf-8',
      },
    });
  } catch (error) {
    console.error('Error generating home page markdown:', error);
    return new Response('Internal Server Error', { status: 500 });
  }
};
```

```typescript
export const GET: APIRoute = async ({ params }) => {
  const slug = params.slug;
  if (!slug) {
    return new Response('Not Found', { status: 404 });
  }

  const blogPosts = await getCollection('blog');
  const post = blogPosts.find((p: CollectionEntry<'blog'>) => p.id === slug);

  if (!post) {
    return new Response('Not Found', { status: 404 });
  }

  try {
    const markdown = await getBlogPostRawMarkdown(post);
    return new Response(markdown, {
      headers: {
        'Content-Type': 'text/markdown; charset=utf-8',
      },
    });
  } catch (error) {
    console.error('Error generating markdown for post:', error);
    return new Response('Internal Server Error', { status: 500 });
  }
};
```

### ViewAsMarkdown Component

The "View as Markdown" buttons are implemented as a simple React component that links to these API routes:

```tsx
export default function ViewAsMarkdown({ href, label = 'View as Markdown' }: ViewAsMarkdownProps) {
  return (
    <a
      href={href}
      className="inline-flex items-center gap-2 px-4 py-2 bg-surface-elevated hover:bg-surface border border-surface rounded-lg text-sm text-heading transition-colors"
      aria-label={label}
    >
      {/* SVG icon */}
      {label}
    </a>
  );
}
```

This component is used in the Biography section and on blog post pages, providing a simple way for users to access the markdown version of any page.

### Benefits of This Approach

This unified approach has several benefits:

1. **DRY Principle** - The same functions generate markdown for both AI context and user viewing
2. **Consistency** - The markdown format is consistent across all uses
3. **Maintainability** - Changes to markdown generation only need to be made in one place
4. **Transparency** - Users can see exactly what content the AI has access to

## 3. Resume PDF Sync

This website does not own CV source or rendering. The resume PDF is built into `public/uploads/resume.pdf` and served at `/uploads/resume.pdf`, and is an artifact only. A separate [CV_management](https://github.com/FridljDa/CV_management) repository holds the CV source (YAML), uses RenderCV to generate the PDF, and syncs the result here via a GitHub Actions workflow that opens a PR with the updated `public/uploads/resume.pdf`. This keeps rendering and tooling in one place and leaves the website repo with just the file to serve.

## Conclusion

These three features demonstrate different aspects of modern web development:

- **The chat interface** showcases real-time streaming, AI integration, and context-aware systems
- **The markdown generation system** illustrates the DRY principle and unified data transformation
- **The resume PDF sync** keeps the website repo simple while a dedicated CV repo owns source and rendering

Together, they create a website that is both functional and maintainable, with clear separation of concerns and automated processes that reduce manual work. The codebase is open source and available on [GitHub](https://github.com/FridljDa/FridljDa.github.io) for anyone interested in exploring the implementation details further.

