Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@
"common.nav.5.href" : "https://docs.seattlecommunitynetwork.org/community/space/",
"common.nav.6.label" : "Donate",
"common.nav.6.href" : "/donate",
"common.nav.7.label" : "Merch!",
"common.nav.7.href" : "https://seattlecommunitynetwork.square.site/",
"common.nav.7.label" : "Blog",
"common.nav.7.href" : "/blogs",
"common.nav.8.label" : "Merch!",
"common.nav.8.href" : "https://seattlecommunitynetwork.square.site/",

"buttons.discord.cta": "Join our Discord server",
"buttons.discord.href": "https://discord.gg/gn4DKF83bP",
Expand Down
28 changes: 13 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"test:e2e": "playwright test",
"test:ui" : "playwright test --ui",
"test:ui": "playwright test --ui",
"test": "npm run test:e2e",
"deploy": "npm run build && gh-pages -d build -t",
"redirects": "node create-redirects.js"
Expand All @@ -39,6 +39,7 @@
"@inlang/paraglide-js": "^2.0.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-svelte": "^8.6.0",
"marked": "^17.0.4",
"modern-normalize": "^3.0.1"
}
}
93 changes: 93 additions & 0 deletions src/lib/blog/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { marked } from 'marked';
import type { GitHubIssue, BlogPost, BlogListResponse } from './types.js';

const GITHUB_REPO = 'Local-Connectivity-Lab/Local-Connectivity-Lab.github.io';
const BLOG_LABEL = 'blog';

// Configure marked for safe HTML output
marked.setOptions({
gfm: true,
breaks: true,
sanitize: false, // We trust GitHub issue content
smartypants: true
});

/**
* Generate a URL-friendly slug from a blog post title
*/
export function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
}

/**
* Transform a GitHub issue into a blog post
*/
export function transformIssueToBlogPost(issue: GitHubIssue): BlogPost {
const content = issue.body || '';
const htmlContent = marked.parse(content) as string;

return {
id: issue.id,
number: issue.number,
title: issue.title,
content,
htmlContent,
slug: generateSlug(issue.title),
publishedAt: issue.created_at,
updatedAt: issue.updated_at,
author: {
name: issue.user.login,
avatar: issue.user.avatar_url,
url: issue.user.html_url
},
githubUrl: issue.html_url,
labels: issue.labels.map(label => label.name)
};
}

/**
* Fetch blog posts from GitHub issues with the blog label
*/
export async function fetchBlogPosts(fetchFn: typeof fetch = fetch): Promise<BlogListResponse> {
const url = `https://api.github.com/repos/${GITHUB_REPO}/issues?labels=${BLOG_LABEL}&state=open&sort=created&direction=desc`;

try {
const response = await fetchFn(url, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'Local-Connectivity-Lab-Website'
}
});

if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}

const issues: GitHubIssue[] = await response.json();
const posts = issues.map(transformIssueToBlogPost);

return {
posts,
total: posts.length
};
} catch (error) {
console.error('Error fetching blog posts:', error);
return {
posts: [],
total: 0
};
}
}

/**
* Find a blog post by its slug
*/
export async function findBlogPostBySlug(slug: string, fetchFn: typeof fetch = fetch): Promise<BlogPost | null> {
const { posts } = await fetchBlogPosts(fetchFn);
return posts.find(post => post.slug === slug) || null;
}
44 changes: 44 additions & 0 deletions src/lib/blog/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export interface GitHubIssue {
id: number;
number: number;
title: string;
body: string | null;
state: 'open' | 'closed';
created_at: string;
updated_at: string;
html_url: string;
user: {
login: string;
avatar_url: string;
html_url: string;
};
labels: Array<{
id: number;
name: string;
color: string;
description: string | null;
}>;
}

export interface BlogPost {
id: number;
number: number;
title: string;
content: string;
htmlContent: string;
slug: string;
publishedAt: string;
updatedAt: string;
author: {
name: string;
avatar: string;
url: string;
};
githubUrl: string;
labels: string[];
}

export interface BlogListResponse {
posts: BlogPost[];
total: number;
}
158 changes: 158 additions & 0 deletions src/lib/components/BlogCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<script lang="ts">
import Panel from '$lib/components/Panel.svelte';
import Button from '$lib/components/Button.svelte';

import type { BlogPost } from '$lib/blog/types.js';

let { post }: { post: BlogPost } = $props();

function formatDate(dateString: string) {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}

function truncateContent(content: string, maxLength = 300) {
// Remove YouTube embeds and other media
let cleanContent = content
// Remove YouTube URLs (various formats)
.replace(/https?:\/\/(www\.)?(youtube\.com\/(embed\/|watch\?v=)|youtu\.be\/)[^\s\n\r\])]*/g, '')
// Remove other video embeds
.replace(/https?:\/\/(www\.)?(vimeo\.com|dailymotion\.com|twitch\.tv)[^\s\n\r\])]*/g, '')
// Remove image markdown
.replace(/!\[.*?\]\(.*?\)/g, '')
// Remove HTML img tags
.replace(/<img[^>]*>/g, '')
// Remove HTML iframe tags (embeds)
.replace(/<iframe[^>]*>.*?<\/iframe>/gs, '')
// Remove HTML video/audio tags
.replace(/<(video|audio)[^>]*>.*?<\/(video|audio)>/gs, '')
// Remove code blocks
.replace(/```[\s\S]*?```/g, '')
// Remove inline code
.replace(/`[^`]*`/g, '')
// Remove markdown links but keep text
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
// Remove extra whitespace and line breaks
.replace(/\s+/g, ' ')
.trim();

if (cleanContent.length <= maxLength) {
return cleanContent;
}

return cleanContent.substring(0, maxLength) + '...';
}
</script>

<article class="blog-card" data-test="blog-post-{post.slug}">
<Panel>
<h2 class="blog-title">
<a href="/blogs/{post.slug}" data-test="blog-link-{post.slug}">
{post.title}
</a>
</h2>

<div class="blog-meta">
<div class="blog-author">
<img
src={post.author.avatar}
alt="{post.author.name} avatar"
width="24"
height="24"
/>
<span>{post.author.name}</span>
</div>

<time datetime={post.publishedAt}>
{formatDate(post.publishedAt)}
</time>
</div>

<div class="blog-content">
{truncateContent(post.content)}
</div>

{#if post.labels.length > 0}
<div class="blog-labels">
{#each post.labels as label}
{#if label !== 'blog'}
<span class="label">{label}</span>
{/if}
{/each}
</div>
{/if}

<a
href="/blogs/{post.slug}"
class="button small"
data-test="blog-read-more-{post.slug}"
>
Read more
</a>
</Panel>
</article>

<style lang="postcss">
.blog-card {
height: 100%;
}

.blog-meta {
display: flex;
align-items: center;
gap: 1em;
margin-bottom: 1rem;
font-size: 0.875rem;
color: var(--color-text-muted);
}

.blog-author {
display: flex;
align-items: center;
gap: 0.5rem;
}

.blog-author img {
border-radius: 50%;
}

.blog-title {
text-align: left;
font-size: 1.25rem;

& a {
color: inherit;
text-decoration: none;
transition: color 0.2s ease;
}
}



.blog-content {
margin-bottom: 1rem;
line-height: 1.6;
color: var(--color-text-secondary);
}

.blog-labels {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}

.label {
display: inline-block;
padding: 0.25rem 0.5rem;
background-color: var(--color-bg-secondary);
border-radius: 0.25rem;
font-size: 0.75rem;
color: var(--color-text-muted);
}

</style>
Loading