diff --git a/messages/en.json b/messages/en.json index c9c14b6..60aa638 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/package-lock.json b/package-lock.json index 577f017..454de7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,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" }, "devDependencies": { @@ -2837,6 +2838,18 @@ "semver": "bin/semver.js" } }, + "node_modules/marked": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz", + "integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3674,21 +3687,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.0.tgz", diff --git a/package.json b/package.json index 5f7afa5..ad5f32c 100644 --- a/package.json +++ b/package.json @@ -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" @@ -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" } } diff --git a/src/lib/blog/api.ts b/src/lib/blog/api.ts new file mode 100644 index 0000000..5315778 --- /dev/null +++ b/src/lib/blog/api.ts @@ -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 { + 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 { + const { posts } = await fetchBlogPosts(fetchFn); + return posts.find(post => post.slug === slug) || null; +} \ No newline at end of file diff --git a/src/lib/blog/types.ts b/src/lib/blog/types.ts new file mode 100644 index 0000000..995c287 --- /dev/null +++ b/src/lib/blog/types.ts @@ -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; +} \ No newline at end of file diff --git a/src/lib/components/BlogCard.svelte b/src/lib/components/BlogCard.svelte new file mode 100644 index 0000000..ac0afb9 --- /dev/null +++ b/src/lib/components/BlogCard.svelte @@ -0,0 +1,158 @@ + + +
+ +

+ + {post.title} + +

+ +
+
+ {post.author.name} avatar + {post.author.name} +
+ + +
+ +
+ {truncateContent(post.content)} +
+ + {#if post.labels.length > 0} +
+ {#each post.labels as label} + {#if label !== 'blog'} + {label} + {/if} + {/each} +
+ {/if} + + + Read more + +
+
+ + \ No newline at end of file diff --git a/src/lib/components/BlogContent.svelte b/src/lib/components/BlogContent.svelte new file mode 100644 index 0000000..6f7822b --- /dev/null +++ b/src/lib/components/BlogContent.svelte @@ -0,0 +1,76 @@ + + +
+ {@html htmlContent} +
+ + \ No newline at end of file diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index 3139684..e77469e 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -42,6 +42,10 @@ { href: m['common.nav.7.href'](), label: m['common.nav.7.label']() + }, + { + href: m['common.nav.8.href'](), + label: m['common.nav.8.label']() } ]; diff --git a/src/routes/blogs/+page.svelte b/src/routes/blogs/+page.svelte new file mode 100644 index 0000000..23f8176 --- /dev/null +++ b/src/routes/blogs/+page.svelte @@ -0,0 +1,49 @@ + + + + + + +

Blog

+ +
+ {#if data.blogs.posts.length > 0} +
+ {#each data.blogs.posts as post} + + {/each} +
+ {:else} + +

No blog posts found. Check back soon for updates!

+
+ {/if} +
+
+ + \ No newline at end of file diff --git a/src/routes/blogs/+page.ts b/src/routes/blogs/+page.ts new file mode 100644 index 0000000..c66e8c4 --- /dev/null +++ b/src/routes/blogs/+page.ts @@ -0,0 +1,12 @@ +import { fetchBlogPosts } from '$lib/blog/api.js'; +import type { PageLoad } from './$types.js'; + +export const prerender = true; + +export const load: PageLoad = async ({ fetch }) => { + const blogData = await fetchBlogPosts(fetch); + + return { + blogs: blogData + }; +}; \ No newline at end of file diff --git a/src/routes/blogs/[slug]/+page.svelte b/src/routes/blogs/[slug]/+page.svelte new file mode 100644 index 0000000..15d8c50 --- /dev/null +++ b/src/routes/blogs/[slug]/+page.svelte @@ -0,0 +1,197 @@ + + + + + +
+
+ +
+ +
+ +

{post.title}

+ +
+
+ {post.author.name} avatar +
+ +
+ + {#if post.updatedAt !== post.publishedAt} + • Updated {formatDate(post.updatedAt)} + {/if} +
+
+
+
+ + {#if post.labels.length > 1} +
+ {#each post.labels as label} + {#if label !== 'blog'} + {label} + {/if} + {/each} +
+ {/if} +
+
+ +
+ + + +
+ +
+ +
+ +
+
+
+
+
+ + \ No newline at end of file diff --git a/src/routes/blogs/[slug]/+page.ts b/src/routes/blogs/[slug]/+page.ts new file mode 100644 index 0000000..ad16967 --- /dev/null +++ b/src/routes/blogs/[slug]/+page.ts @@ -0,0 +1,28 @@ +import { error } from '@sveltejs/kit'; +import { findBlogPostBySlug, fetchBlogPosts } from '$lib/blog/api.js'; +import type { PageLoad } from './$types.js'; + +export const prerender = true; + +export const load: PageLoad = async ({ params, fetch }) => { + const post = await findBlogPostBySlug(params.slug, fetch); + + if (!post) { + throw error(404, 'Blog post not found'); + } + + return { + post + }; +}; + +// Generate all possible blog post routes at build time +export async function entries() { + try { + const { posts } = await fetchBlogPosts(); + return posts.map(post => ({ slug: post.slug })); + } catch (err) { + console.error('Error generating blog post entries:', err); + return []; + } +} \ No newline at end of file diff --git a/svelte.config.js b/svelte.config.js index 2d8f0e4..166a59e 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -14,7 +14,8 @@ const config = { '*', '/donate', '/about-us', - '/our-sites' + '/our-sites', + '/blogs' ], handleUnseenRoutes: 'warn' }