refactor: remove blog-related files and rename project to linkat-directory

Remove all blog-related components, services, and routes as they are no longer needed. Rename the project from "website-template" to "linkat-directory" in package.json and package-lock.json to reflect the new purpose of the application.

ewancroft.uk f2f78e5b bc43797b

verified
+2 -2
package-lock.json
··· 1 1 { 2 - "name": "website-template", 2 + "name": "linkat-directory", 3 3 "version": "0.0.1", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 - "name": "website-template", 8 + "name": "linkat-directory", 9 9 "version": "0.0.1", 10 10 "dependencies": { 11 11 "@resvg/resvg-js": "^2.6.2",
+1 -1
package.json
··· 1 1 { 2 - "name": "website-template", 2 + "name": "linkat-directory", 3 3 "version": "0.0.1", 4 4 "type": "module", 5 5 "scripts": {
-24
src/lib/components/archive/StatsDisplay.svelte
··· 1 - <script lang="ts"> 2 - import { formatNumber } from "$utils/formatters"; 3 - 4 - export let totalReadTime: string; 5 - export let totalWordCount: number; 6 - export let postCount: number | undefined = undefined; 7 - 8 - // Determine singular or plural for word count 9 - $: wordLabel = totalWordCount === 1 ? "word" : "words"; 10 - $: postLabel = postCount === 1 ? "post" : "posts"; 11 - </script> 12 - 13 - {#if postCount !== undefined} 14 - <p class="text-sm opacity-50 mb-4 ml-2"> 15 - {totalReadTime} read time • {formatNumber(totalWordCount)} 16 - {wordLabel} • {formatNumber(postCount)} 17 - {postLabel} 18 - </p> 19 - {:else} 20 - <div class="mb-6 ml-4 text-sm opacity-70"> 21 - <p>Total Read Time: {totalReadTime}</p> 22 - <p>Total Word Count: {formatNumber(totalWordCount)} {wordLabel}</p> 23 - </div> 24 - {/if}
-39
src/lib/components/archive/YearContent.svelte
··· 1 - <script lang="ts"> 2 - import { fly, fade } from "svelte/transition"; 3 - import { quintOut } from "svelte/easing"; 4 - import MonthSection from "./MonthSection.svelte"; 5 - import { calculateTotalReadTime, calculateTotalWordCount, formatReadTime } from "$utils/tally"; 6 - import StatsDisplay from "./StatsDisplay.svelte"; 7 - 8 - export const year: number = 0; 9 - export let months: Record<string, any[]>; 10 - export let localeLoaded: boolean; 11 - 12 - // Calculate yearly totals 13 - $: rawYearlyTotalReadTime = Object.values(months).reduce((total, postsInMonth) => { 14 - return total + calculateTotalReadTime(postsInMonth); 15 - }, 0); 16 - $: yearlyTotalReadTime = formatReadTime(rawYearlyTotalReadTime); 17 - 18 - $: yearlyTotalWordCount = Object.values(months).reduce((total, postsInMonth) => { 19 - return total + calculateTotalWordCount(postsInMonth); 20 - }, 0); 21 - </script> 22 - 23 - <div 24 - in:fly={{ y: 20, duration: 300, delay: 50, easing: quintOut }} 25 - out:fade={{ duration: 200 }} 26 - class="year-content" 27 - > 28 - <StatsDisplay totalReadTime={yearlyTotalReadTime} totalWordCount={yearlyTotalWordCount} /> 29 - 30 - {#each Object.entries(months) as [monthName, postsInMonth], monthIndex} 31 - <MonthSection 32 - {monthName} 33 - {postsInMonth} 34 - {monthIndex} 35 - {localeLoaded} 36 - 37 - /> 38 - {/each} 39 - </div>
-62
src/lib/components/archive/YearTabs.svelte
··· 1 - <script lang="ts"> 2 - export let groupedByYear: any[]; 3 - export let activeYear: number; 4 - 5 - function setActiveYear(year: number) { 6 - activeYear = year; 7 - } 8 - 9 - // Calculate the active tab index more reliably 10 - $: activeTabIndex = groupedByYear.findIndex((g) => g.year === activeYear); 11 - $: indicatorLeft = activeTabIndex >= 0 ? activeTabIndex * 100 : 0; 12 - </script> 13 - 14 - <div 15 - class="flex mb-6 ml-4 border-b border-[var(--button-bg)] overflow-x-auto relative tabs-container" 16 - > 17 - <div class="tab-indicator-container absolute bottom-0 left-0 h-0.5 w-full"> 18 - <div 19 - class="tab-indicator bg-[var(--link-color)] h-full absolute bottom-0 transition-all duration-300 ease-out" 20 - style="left: {indicatorLeft}px; width: 100px;" 21 - ></div> 22 - </div> 23 - 24 - {#each groupedByYear as { year }, i} 25 - <button 26 - class="w-[100px] min-w-[100px] px-4 py-2 font-medium transition-all duration-300 relative z-10 text-center 27 - {activeYear === year 28 - ? 'text-[var(--link-color)]' 29 - : 'text-[var(--text-color)] opacity-80 hover:text-[var(--link-hover-color)]'}" 30 - onclick={() => setActiveYear(year)} 31 - > 32 - <span 33 - class="relative {activeYear === year 34 - ? 'transform transition-transform duration-300 scale-105' 35 - : ''}" 36 - > 37 - {year} 38 - </span> 39 - </button> 40 - {/each} 41 - </div> 42 - 43 - <style> 44 - /* Custom scrollbar styling for tabs container */ 45 - .tabs-container::-webkit-scrollbar { 46 - height: 6px; 47 - } 48 - 49 - .tabs-container::-webkit-scrollbar-track { 50 - background: var(--header-footer-bg); 51 - border-radius: 0px; 52 - } 53 - 54 - .tabs-container::-webkit-scrollbar-thumb { 55 - background: var(--button-bg); 56 - border-radius: 0px; 57 - } 58 - 59 - .tabs-container::-webkit-scrollbar-thumb:hover { 60 - background: var(--button-hover-bg); 61 - } 62 - </style>
-5
src/lib/components/archive/index.ts
··· 1 - export { default as YearTabs } from "./YearTabs.svelte"; 2 - export { default as YearContent } from "./YearContent.svelte"; 3 - export { default as MonthSection } from "./MonthSection.svelte"; 4 - export { default as ArchiveCard } from "./ArchiveCard.svelte"; 5 - export { default as StatsDisplay } from "./StatsDisplay.svelte";
-11
src/lib/components/post/PostContent.svelte
··· 1 - <script lang="ts"> 2 - import type { Post } from "$components/shared"; 3 - 4 - export let post: Post; 5 - </script> 6 - 7 - <hr class="my-6 border-[var(--button-bg)]" /> 8 - <article class="prose dark:prose-invert mx-auto text-center"> 9 - {@html post.content} 10 - </article> 11 - <hr class="my-6 border-[var(--button-bg)]" />
-62
src/lib/components/post/PostHead.svelte
··· 1 - <script lang="ts"> 2 - import { getStores } from "$app/stores"; 3 - const { page } = getStores(); 4 - import type { Post } from "$components/shared"; 5 - import { env } from "$env/dynamic/public"; 6 - 7 - export let post: Post | undefined; 8 - </script> 9 - 10 - <svelte:head> 11 - {#if post !== undefined} 12 - <title>{post?.title} - Blog - Site Name</title> 13 - <meta name="description" content={post.excerpt} /> 14 - <meta 15 - name="keywords" 16 - content="personal blog, Blog - Site Name" 17 - /> 18 - 19 - <!-- Open Graph / Facebook --> 20 - <meta property="og:type" content="article" /> 21 - <meta property="og:url" content={$page.url.origin + $page.url.pathname} /> 22 - <meta 23 - property="og:title" 24 - content={`${post.title} - Blog - Site Name`} 25 - /> 26 - <meta property="og:description" content={post.excerpt} /> 27 - <meta property="og:site_name" content="Blog - Site Name" /> 28 - {#if $page.url.origin} 29 - <meta property="og:image" content={$page.url.origin + "/embed/blog.png"} /> 30 - {/if} 31 - <meta property="og:image:width" content="1200" /> 32 - <meta property="og:image:height" content="630" /> 33 - <meta 34 - property="article:published_time" 35 - content={post.createdAt.toISOString()} 36 - /> 37 - <meta property="article:word_count" content={post.wordCount.toString()} /> 38 - 39 - <!-- Fediverse --> 40 - {#if env.PUBLIC_ACTIVITYPUB_USER && env.PUBLIC_ACTIVITYPUB_USER.length > 0} 41 - <meta name="fediverse:creator" content={env.PUBLIC_ACTIVITYPUB_USER}> 42 - {/if} 43 - 44 - <!-- Twitter --> 45 - <meta name="twitter:card" content="summary_large_image" /> 46 - <meta name="twitter:url" content={$page.url.origin + $page.url.pathname} /> 47 - <meta 48 - name="twitter:title" 49 - content={`${post.title} - Blog - Site Name`} 50 - /> 51 - <meta name="twitter:description" content={post.excerpt} /> 52 - {#if $page.url.origin} 53 - <meta name="twitter:image" content={$page.url.origin + "/embed/blog.png"} /> 54 - {/if} 55 - {:else} 56 - <title>Post Not Found - Blog - Site Name</title> 57 - <meta 58 - name="description" 59 - content="The requested blog post could not be found." 60 - /> 61 - {/if} 62 - </svelte:head>
-100
src/lib/components/post/PostHeader.svelte
··· 1 - <script lang="ts"> 2 - import { fade } from "svelte/transition"; 3 - import { formatRelativeTime, formatDate } from "$utils/formatters"; 4 - import { ShareIcons } from "$components/icons"; 5 - import { formatNumber } from "$utils/formatters"; 6 - import type { Post } from "$components/shared"; 7 - import { onMount } from 'svelte'; 8 - 9 - let { post, profile, rkey, localeLoaded } = $props<{ 10 - post: Post; 11 - profile: any; 12 - rkey: string; 13 - localeLoaded: boolean; 14 - }>(); 15 - 16 - // Determine singular or plural for word count 17 - let wordLabel = post.wordCount === 1 ? "word" : "words"; 18 - 19 - let displayDate = $derived(localeLoaded && post.createdAt ? formatRelativeTime(post.createdAt) : 'datetime loading...'); 20 - let absoluteDisplayDate = $derived(localeLoaded && post.createdAt ? formatDate(new Date(post.createdAt)) : 'datetime loading...'); 21 - 22 - let fediverseCreator = $state(''); 23 - 24 - onMount(() => { 25 - const metaTag = document.querySelector('meta[name="fediverse:creator"]'); 26 - if (metaTag) { 27 - fediverseCreator = metaTag.getAttribute('content') || ''; 28 - } else if (profile?.did) { 29 - fediverseCreator = `https://bsky.app/profile/${profile.handle}`; 30 - } 31 - }); 32 - </script> 33 - 34 - <!-- Title with more breathing room --> 35 - <div class="flex items-center justify-between"> 36 - <div class="flex-1"></div> 37 - <h1 class="text-center my-12 flex-grow leading-relaxed">{post.title}</h1> 38 - <div class="flex-1"></div> 39 - </div> 40 - 41 - <!-- Metadata section with improved spacing and grouping --> 42 - <div class="text-center text-[var(--text-color)] opacity-80 mb-12 space-y-4"> 43 - <!-- Author and date info --> 44 - <div class="space-y-2"> 45 - <p class="text-base"> 46 - last updated by <a 47 - href={`https://bsky.app/profile/${profile?.handle}`} 48 - class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] font-medium" 49 - >{#key profile?.displayName} 50 - <span transition:fade={{ duration: 200 }}>{profile?.displayName}</span> 51 - {/key}</a 52 - > 53 - <span transition:fade={{ duration: 200 }}>{displayDate}</span> 54 - </p> 55 - <p class="text-sm opacity-70"> 56 - <span transition:fade={{ duration: 200 }}>({absoluteDisplayDate})</span> 57 - </p> 58 - </div> 59 - 60 - <!-- Links section with subtle separation --> 61 - <div class="pt-3 mt-1"> 62 - <p class="text-sm opacity-75"> 63 - View on <a 64 - href={`https://whtwnd.nat.vg/${profile?.did}/${rkey}`} 65 - onerror={(e) => { 66 - e.preventDefault(); 67 - if (e.target instanceof HTMLAnchorElement) { 68 - e.target.href = `https://whtwnd.com/${profile?.did}/${rkey}`; 69 - } 70 - }} 71 - class="hover:text-[var(--link-hover-color)] underline decoration-dotted">WhiteWind</a 72 - > 73 - or see the record at 74 - <a 75 - href={`https://atproto.at/viewer?uri=${profile?.did}/com.whtwnd.blog.entry/${rkey}`} 76 - onerror={(e) => { 77 - e.preventDefault(); 78 - if (e.target instanceof HTMLAnchorElement) { 79 - e.target.href = `https://pdsls.dev/at://${profile?.did}/com.whtwnd.blog.entry/${rkey}`; 80 - e.target.textContent = 'PDSls'; 81 - } 82 - }} 83 - class="hover:text-[var(--link-hover-color)] underline decoration-dotted">atproto.at</a 84 - > 85 - </p> 86 - </div> 87 - 88 - <!-- Reading time with subtle emphasis --> 89 - <div class="px-2 py-1 inline-block"> 90 - <p class="text-sm opacity-70"> 91 - {Math.ceil(post.wordCount / 200)} min read • {formatNumber(post.wordCount)} 92 - {wordLabel} 93 - </p> 94 - </div> 95 - 96 - <!-- Share icons with reduced spacing --> 97 - <div class="pt-1"> 98 - <ShareIcons title={post.title} {profile} /> 99 - </div> 100 - </div>
-34
src/lib/components/post/PostNavigation.svelte
··· 1 - <script lang="ts"> 2 - import type { Post } from "$components/shared"; 3 - 4 - export let adjacentPosts: { 5 - previous: Post | null; 6 - next: Post | null; 7 - }; 8 - </script> 9 - 10 - <div class="flex justify-between mt-8 mb-8 gap-4"> 11 - {#if adjacentPosts.previous} 12 - <a 13 - href="/blog/{adjacentPosts.previous.rkey}" 14 - class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] max-w-[45%] truncate" 15 - title={adjacentPosts.previous.title} 16 - > 17 - ← {adjacentPosts.previous.title} 18 - </a> 19 - {:else} 20 - <span></span> 21 - {/if} 22 - 23 - {#if adjacentPosts.next} 24 - <a 25 - href="/blog/{adjacentPosts.next.rkey}" 26 - class="text-[var(--link-color)] hover:text-[var(--link-hover-color)] max-w-[45%] truncate" 27 - title={adjacentPosts.next.title} 28 - > 29 - {adjacentPosts.next.title} → 30 - </a> 31 - {:else} 32 - <span></span> 33 - {/if} 34 - </div>
-4
src/lib/components/post/index.ts
··· 1 - export { default as PostHead } from "./PostHead.svelte"; 2 - export { default as PostHeader } from "./PostHeader.svelte"; 3 - export { default as PostContent } from "./PostContent.svelte"; 4 - export { default as PostNavigation } from "./PostNavigation.svelte";
-3
src/lib/parser/index.ts
··· 1 - export { parse } from './parser.ts'; 2 - export { customSchema } from './schema.ts'; 3 - export { rehypeUpgradeImage } from './plugins.ts';
-34
src/lib/parser/parser.ts
··· 1 - import { extractTextFromMarkdown, calculateWordCount } from "$utils/textProcessor"; 2 - import { createMarkdownProcessor } from "./processor"; 3 - import type { Post, MarkdownPost } from "./types"; 4 - 5 - export async function parse(mdposts: Map<string, MarkdownPost>): Promise<Map<string, Post>> { 6 - const posts: Map<string, Post> = new Map(); 7 - const processor = createMarkdownProcessor(); 8 - 9 - for (const [rkey, post] of mdposts) { 10 - const parsedHtml = String( 11 - await processor.process(post.mdcontent) 12 - ); 13 - 14 - // Ensure mdcontent is a string before processing 15 - const markdownContent = post.mdcontent || ''; 16 - 17 - // Extract plain text for excerpt 18 - const excerpt = await extractTextFromMarkdown(markdownContent); 19 - 20 - // Calculate word count from markdown content 21 - const wordCount = calculateWordCount(markdownContent); 22 - 23 - posts.set(rkey, { 24 - title: post.title, 25 - rkey: post.rkey, 26 - createdAt: post.createdAt, 27 - content: parsedHtml, 28 - excerpt, 29 - wordCount, 30 - }); 31 - } 32 - 33 - return posts; 34 - }
-25
src/lib/parser/plugins.ts
··· 1 - import type { Node, Root, Element, Plugin } from "./types"; 2 - 3 - // Automatically enforce https on PDS images. Heavily inspired by WhiteWind's blob replacer: 4 - // https://github.com/whtwnd/whitewind-blog/blob/7eb8d4623eea617fd562b93d66a0e235323a2f9a/frontend/src/services/DocProvider.tsx#L90 5 - // In theory we could also use their cache, but I'd like to rely on their API as little as possible, opting to pull from the PDS instead. 6 - const upgradeImage = (child: Node): void => { 7 - if (child.type !== "element") { 8 - return; 9 - } 10 - const elem = child as Element; 11 - if (elem.tagName === "img") { 12 - // Ensure https 13 - const src = elem.properties.src; 14 - if (src !== undefined && typeof src === "string") { 15 - elem.properties.src = src.replace(/http:\/\//, "https://"); 16 - } 17 - } 18 - elem.children.forEach((child) => upgradeImage(child)); 19 - }; 20 - 21 - export const rehypeUpgradeImage: Plugin<[], Root, Node> = () => { 22 - return (tree) => { 23 - tree.children.forEach((child) => upgradeImage(child)); 24 - }; 25 - };
-21
src/lib/parser/processor.ts
··· 1 - import rehypeStringify from "rehype-stringify"; 2 - import remarkParse from "remark-parse"; 3 - import remarkGfm from "remark-gfm"; 4 - import remarkRehype from "remark-rehype"; 5 - import rehypeSanitize from "rehype-sanitize"; 6 - import rehypeRaw from "rehype-raw"; 7 - import { unified } from "unified"; 8 - import { customSchema } from "./schema"; 9 - import { rehypeUpgradeImage } from "./plugins"; 10 - import type { Schema } from "./types"; 11 - 12 - export const createMarkdownProcessor = () => { 13 - return unified() 14 - .use(remarkParse, { fragment: true }) // Parse the MD 15 - .use(remarkGfm) // Parse GH specific MD 16 - .use(remarkRehype, { allowDangerousHtml: true }) // Convert to HTML 17 - .use(rehypeRaw) // Parse HTML that exists as raw text leftover from MD parse 18 - .use(rehypeUpgradeImage) 19 - .use(rehypeSanitize, customSchema as Schema) // Sanitize the HTML 20 - .use(rehypeStringify); // Stringify 21 - };
-43
src/lib/parser/schema.ts
··· 1 - import { defaultSchema } from "rehype-sanitize"; 2 - import type { Schema } from "./types"; 3 - 4 - // WhiteWind's own custom schema: 5 - // https://github.com/whtwnd/whitewind-blog/blob/7eb8d4623eea617fd562b93d66a0e235323a2f9a/frontend/src/services/DocProvider.tsx#L122 6 - export const customSchema: Schema = { 7 - ...defaultSchema, 8 - attributes: { 9 - ...defaultSchema.attributes, 10 - font: [...(defaultSchema.attributes?.font ?? []), "color"], 11 - blockquote: [ 12 - ...(defaultSchema.attributes?.blockquote ?? []), 13 - // bluesky 14 - "className", 15 - "dataBlueskyUri", 16 - "dataBlueskyCid", 17 - // instagram 18 - "dataInstgrmCaptioned", 19 - "dataInstgrmPermalink", 20 - "dataInstgrmVersion", 21 - ], 22 - iframe: [ 23 - "width", 24 - "height", 25 - "title", 26 - "frameborder", 27 - "allow", 28 - "referrerpolicy", 29 - "allowfullscreen", 30 - "style", 31 - "seamless", 32 - ["src", /https:\/\/(www.youtube.com|bandcamp.com)\/.*/], 33 - ], 34 - section: ["dataFootnotes", "className"], 35 - }, 36 - tagNames: [ 37 - ...(defaultSchema.tagNames ?? []), 38 - "font", 39 - "mark", 40 - "iframe", 41 - "section", 42 - ], 43 - };
-8
src/lib/parser/types.ts
··· 1 - import type { Schema } from "../../../node_modules/rehype-sanitize/lib"; 2 - import type { Node } from "unist"; 3 - import type { Root, Element } from "hast"; 4 - import type { Plugin } from "unified"; 5 - import type { Post, MarkdownPost } from "$components/shared"; 6 - 7 - export type { Schema, Node, Root, Element, Plugin, Post, MarkdownPost }; 8 -
-189
src/lib/services/blogService.ts
··· 1 - import { getProfile } from "$components/profile/profile"; 2 - import type { Profile, MarkdownPost, Post, BlogServiceResult } from "$components/shared"; 3 - import { parse } from "$lib/parser"; 4 - 5 - // Cache for blog data 6 - let profile: Profile; 7 - let allPosts: Map<string, Post>; 8 - let sortedPosts: Post[] = []; 9 - 10 - /** 11 - * Validates and processes a single blog record 12 - */ 13 - function processRecord(data: any): MarkdownPost | null { 14 - const matches = data["uri"].split("/"); 15 - const rkey = matches[matches.length - 1]; 16 - 17 - // Enhanced debugging for development 18 - if (process.env.NODE_ENV === 'development') { 19 - console.log('=== Record Debug Info ==='); 20 - console.log('URI:', data["uri"]); 21 - console.log('Data structure keys:', Object.keys(data)); 22 - } 23 - 24 - // Try both access patterns to be safe 25 - const record = data["value"] || data.value; 26 - 27 - if (!record) { 28 - console.warn(`No record value found for ${rkey}`, { 29 - dataKeys: Object.keys(data), 30 - }); 31 - return null; 32 - } 33 - 34 - // Validate URI format and visibility 35 - if ( 36 - !matches || 37 - matches.length !== 5 || 38 - !record || 39 - (record["visibility"] && record["visibility"] !== "public") 40 - ) { 41 - if (process.env.NODE_ENV === 'development') { 42 - console.warn('Post skipped due to validation failure:', { 43 - rkey, 44 - matchesLength: matches?.length, 45 - hasRecord: !!record, 46 - visibility: record?.["visibility"], 47 - }); 48 - } 49 - return null; 50 - } 51 - 52 - // Extract fields with fallback patterns 53 - const content = record["content"] || record.content || (record.value && record.value.content); 54 - const title = record["title"] || record.title || (record.value && record.value.title); 55 - const createdAt = record["createdAt"] || record.createdAt || (record.value && record.value.createdAt); 56 - 57 - // Skip if missing required content 58 - if (!content) { 59 - console.warn(`Skipping post with missing content: ${rkey}`); 60 - return null; 61 - } 62 - 63 - // Handle createdAt - use current time as fallback for missing dates 64 - let createdAtDate: Date; 65 - if (!createdAt) { 66 - console.warn(`Post missing createdAt, using current time: ${rkey}`); 67 - createdAtDate = new Date(); 68 - } else { 69 - createdAtDate = new Date(createdAt); 70 - 71 - // Skip posts with invalid dates 72 - if (isNaN(createdAtDate.getTime())) { 73 - console.warn(`Skipping post with invalid date: ${rkey}`, { 74 - rawCreatedAt: createdAt, 75 - }); 76 - return null; 77 - } 78 - } 79 - 80 - // Use title if available, otherwise generate one 81 - const finalTitle = title || `Untitled Post (${rkey})`; 82 - 83 - return { 84 - title: finalTitle, 85 - createdAt: createdAtDate, 86 - mdcontent: content, 87 - rkey, 88 - }; 89 - } 90 - 91 - /** 92 - * Fetches and processes all blog posts 93 - */ 94 - export async function loadAllPosts(fetch: typeof window.fetch): Promise<BlogServiceResult> { 95 - try { 96 - // Load profile if not cached 97 - if (profile === undefined) { 98 - profile = await getProfile(fetch); 99 - } 100 - 101 - // Load posts if not cached 102 - if (allPosts === undefined) { 103 - const rawResponse = await fetch( 104 - `${profile.pds}/xrpc/com.atproto.repo.listRecords?repo=${profile.did}&collection=com.whtwnd.blog.entry` 105 - ); 106 - 107 - if (!rawResponse.ok) { 108 - throw new Error(`Failed to fetch posts: ${rawResponse.status}`); 109 - } 110 - 111 - const response = await rawResponse.json(); 112 - 113 - if (!response.records || response.records.length === 0) { 114 - allPosts = new Map(); 115 - sortedPosts = []; 116 - } else { 117 - const mdposts: Map<string, MarkdownPost> = new Map(); 118 - 119 - // Process all records 120 - for (const data of response.records) { 121 - const processedPost = processRecord(data); 122 - if (processedPost) { 123 - mdposts.set(processedPost.rkey, processedPost); 124 - } 125 - } 126 - 127 - console.log(`Successfully processed ${mdposts.size} posts out of ${response.records.length} total records`); 128 - 129 - // Parse markdown content 130 - allPosts = await parse(mdposts); 131 - sortedPosts = Array.from(allPosts.values()).sort( 132 - (a, b) => b.createdAt.getTime() - a.createdAt.getTime() 133 - ); 134 - 135 - // Assign postNumber based on chronological order (1 = latest, 2 = second latest, etc.) 136 - // Assign postNumber based on reverse chronological order (total posts = latest, 1 = oldest) 137 - const totalPosts = sortedPosts.length; 138 - sortedPosts.forEach((post, index) => { 139 - post.postNumber = totalPosts - index; 140 - }); 141 - } 142 - } 143 - 144 - return { 145 - posts: allPosts, 146 - profile, 147 - sortedPosts, 148 - getPost: (rkey: string) => allPosts.get(rkey) || null, 149 - getAdjacentPosts: (rkey: string): { previous: Post | null; next: Post | null } => { 150 - const index = sortedPosts.findIndex((post) => post.rkey === rkey); 151 - return { 152 - previous: index > 0 ? sortedPosts[index - 1] : null, 153 - next: index < sortedPosts.length - 1 ? sortedPosts[index + 1] : null, 154 - }; 155 - }, 156 - }; 157 - } catch (error) { 158 - console.error("Error in loadAllPosts:", error); 159 - return { 160 - posts: new Map(), 161 - profile: profile || ({} as Profile), 162 - sortedPosts: [], 163 - getPost: () => null, 164 - getAdjacentPosts: () => ({ previous: null, next: null }), 165 - }; 166 - } 167 - } 168 - 169 - /** 170 - * Gets the latest N blog posts (for homepage display) 171 - */ 172 - export async function getLatestPosts(fetch: typeof window.fetch, limit: number = 3): Promise<Post[]> { 173 - try { 174 - const { sortedPosts } = await loadAllPosts(fetch); 175 - return sortedPosts.slice(0, limit); 176 - } catch (error) { 177 - console.error("Error fetching latest posts:", error); 178 - return []; 179 - } 180 - } 181 - 182 - /** 183 - * Clears the cache (useful for testing or force refresh) 184 - */ 185 - export function clearCache(): void { 186 - profile = undefined as any; 187 - allPosts = undefined as any; 188 - sortedPosts = []; 189 - }
-8
src/routes/blog/+layout.ts
··· 1 - import { loadAllPosts } from "$services/blogService"; 2 - 3 - export const prerender = false; 4 - export const trailingSlash = "never"; 5 - 6 - export async function load({ fetch }) { 7 - return await loadAllPosts(fetch); 8 - }
-199
src/routes/blog/+page.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from "svelte"; 3 - import YearTabs from "$components/archive/YearTabs.svelte"; 4 - import YearContent from "$components/archive/YearContent.svelte"; 5 - import { getStores } from "$app/stores"; 6 - const { page } = getStores(); 7 - const { data } = $props(); 8 - import type { Post } from "$components/shared"; 9 - 10 - // Get posts from data with enhanced validation 11 - const posts = $derived( 12 - Array.from((data.posts || new Map()).values() as Iterable<Post>) 13 - .filter((post) => { 14 - // Enhanced validation for posts 15 - const hasValidTitle = post.title && typeof post.title === 'string'; 16 - const hasValidDate = post.createdAt instanceof Date && !isNaN(post.createdAt.getTime()); 17 - const hasValidContent = post.content && typeof post.content === 'string'; 18 - const hasValidRkey = post.rkey && typeof post.rkey === 'string'; 19 - 20 - const isValid = hasValidTitle && hasValidDate && hasValidContent && hasValidRkey; 21 - 22 - if (!isValid && process.env.NODE_ENV === 'development') { 23 - console.warn('Invalid post filtered out:', { 24 - title: post.title, 25 - rkey: post.rkey, 26 - hasValidTitle, 27 - hasValidDate, 28 - hasValidContent, 29 - hasValidRkey, 30 - createdAt: post.createdAt, 31 - }); 32 - } 33 - 34 - return isValid; 35 - }) 36 - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) 37 - ); 38 - 39 - // State to track if locale has been properly loaded 40 - let localeLoaded = $state(false); 41 - 42 - onMount(() => { 43 - // Set a brief timeout to ensure the browser has time to determine locale 44 - setTimeout(() => { 45 - localeLoaded = true; 46 - }, 10); 47 - }); 48 - 49 - // Helper function to get only month name 50 - function getMonthName(date: Date): string { 51 - try { 52 - return new Intl.DateTimeFormat( 53 - typeof window !== "undefined" ? window.navigator.language : "en-GB", 54 - { month: "long" } 55 - ).format(date); 56 - } catch (error) { 57 - console.warn('Error formatting month name:', error); 58 - return date.toLocaleDateString('en-GB', { month: 'long' }); 59 - } 60 - } 61 - 62 - // Group posts by year and month 63 - type YearMonthGroup = { 64 - year: number; 65 - months: Record<string, Post[]>; 66 - }; 67 - 68 - const groupedByYear = $derived( 69 - (() => { 70 - if (!posts || posts.length === 0) { 71 - return []; 72 - } 73 - 74 - const groups: Record<number, Record<string, Post[]>> = {}; 75 - 76 - posts.forEach((post) => { 77 - try { 78 - const year = post.createdAt.getFullYear(); 79 - const month = getMonthName(post.createdAt); 80 - 81 - if (!groups[year]) groups[year] = {}; 82 - if (!groups[year][month]) groups[year][month] = []; 83 - 84 - groups[year][month].push(post); 85 - } catch (error) { 86 - console.warn('Error grouping post:', { post, error }); 87 - } 88 - }); 89 - 90 - // Convert to array of year groups sorted by year (descending) 91 - return Object.entries(groups) 92 - .sort(([yearA], [yearB]) => Number(yearB) - Number(yearA)) 93 - .map(([year, months]) => ({ 94 - year: Number(year), 95 - months, 96 - })); 97 - })() as YearMonthGroup[] 98 - ); 99 - 100 - // State for active year tab 101 - let activeYear = $state(0); 102 - 103 - // Set initial active year when data is loaded 104 - $effect(() => { 105 - if (groupedByYear.length > 0) { 106 - activeYear = groupedByYear[0].year; 107 - } 108 - }); 109 - 110 - // Computed loading and error states 111 - const isLoading = $derived(!localeLoaded); 112 - const hasData = $derived(data && data.posts && data.posts.size > 0); 113 - const hasValidPosts = $derived(posts && posts.length > 0); 114 - const hasProfile = $derived(data && data.profile); 115 - </script> 116 - 117 - <svelte:head> 118 - <title>Blog - Site Name</title> 119 - <meta 120 - name="description" 121 - content="Welcome to Blog - Site Name - Keywords" 122 - /> 123 - <meta 124 - name="keywords" 125 - content="personal blog, Blog - Site Name" 126 - /> 127 - <link 128 - rel="alternate" 129 - type="application/rss+xml" 130 - title="Blog - Site Name RSS Feed" 131 - href="{$page.url.origin}/blog/rss" 132 - /> 133 - 134 - <!-- Open Graph / Facebook --> 135 - <meta property="og:type" content="website" /> 136 - <meta property="og:url" content={$page.url.origin + $page.url.pathname} /> 137 - <meta property="og:title" content="Blog - Site Title" /> 138 - <meta 139 - property="og:description" 140 - content="Welcome to Blog - Site Name - Keywords" 141 - /> 142 - <meta property="og:site_name" content="Blog - Site Name" /> 143 - {#if $page.url.origin} 144 - <meta property="og:image" content={$page.url.origin + "/embed/blog.png"} /> 145 - {/if} 146 - <meta property="og:image:width" content="1200" /> 147 - <meta property="og:image:height" content="630" /> 148 - 149 - <!-- Twitter --> 150 - <meta name="twitter:card" content="summary_large_image" /> 151 - <meta name="twitter:url" content={$page.url.origin + $page.url.pathname} /> 152 - <meta name="twitter:title" content="Blog - Site Title" /> 153 - <meta 154 - name="twitter:description" content="Keywords" 155 - /> 156 - {#if $page.url.origin} 157 - <meta name="twitter:image" content={$page.url.origin + "/embed/blog.png"} /> 158 - {/if} 159 - </svelte:head> 160 - 161 - {#if isLoading} 162 - <div 163 - class="flex justify-center items-center min-h-[200px] text-lg text-[var(--text-color)] opacity-70" 164 - > 165 - Loading... 166 - </div> 167 - {:else if !hasProfile} 168 - <div 169 - class="flex flex-col items-center justify-center min-h-[200px] text-lg text-[var(--text-color)] opacity-70 text-center" 170 - > 171 - <p>Unable to load profile data.</p> 172 - <p class="mt-2 text-sm">Please try refreshing the page.</p> 173 - </div> 174 - {:else if !hasData} 175 - <div 176 - class="flex flex-col items-center justify-center min-h-[200px] text-lg text-[var(--text-color)] opacity-70 text-center" 177 - > 178 - <p>No blog data available.</p> 179 - <p class="mt-2 text-sm">This blog uses the <a href="https://whtwnd.com">WhiteWind</a> blogging lexicon, 180 - <code>com.whtwnd.blog.entry</code>, but there seem to be no records available.</p> 181 - </div> 182 - {:else if !hasValidPosts} 183 - <div 184 - class="flex flex-col items-center justify-center min-h-[200px] text-lg text-[var(--text-color)] opacity-70 text-center" 185 - > 186 - <p>No valid blog posts found.</p> 187 - <p class="mt-2 text-sm">Posts were found but none have valid content, titles, and dates.</p> 188 - </div> 189 - {:else} 190 - <!-- Year tabs with animated indicator --> 191 - <YearTabs {groupedByYear} bind:activeYear /> 192 - 193 - <!-- Content for active year with animations --> 194 - {#each groupedByYear as { year, months } (year)} 195 - {#if year === activeYear} 196 - <YearContent {year} {months} {localeLoaded} /> 197 - {/if} 198 - {/each} 199 - {/if}
-50
src/routes/blog/[rkey]/+page.svelte
··· 1 - <script lang="ts" module> 2 - declare global { 3 - interface Window { 4 - $page: { 5 - url: URL; 6 - }; 7 - } 8 - } 9 - </script> 10 - 11 - <script lang="ts"> 12 - import { onMount } from "svelte"; 13 - import type { Post } from "$components/shared"; 14 - import { 15 - PostHead, 16 - PostHeader, 17 - PostContent, 18 - PostNavigation, 19 - } from "$components/post"; 20 - import { NotFoundMessage } from "$components/shared"; 21 - 22 - let { data }: { data: any } = $props(); 23 - let post = $derived(data.post as Post); 24 - let adjacentPosts = $derived(data.adjacentPosts); 25 - 26 - // State to track if locale has been properly loaded 27 - let localeLoaded = $state(false); 28 - 29 - onMount(() => { 30 - // Set localeLoaded to true when component is mounted in the browser 31 - localeLoaded = true; 32 - }); 33 - </script> 34 - 35 - <PostHead {post} /> 36 - 37 - {#if post !== undefined} 38 - <div class="max-w-4xl mx-auto px-4"> 39 - <PostHeader 40 - {post} 41 - profile={data.profile} 42 - rkey={data.rkey} 43 - {localeLoaded} 44 - /> 45 - <PostContent {post} /> 46 - <PostNavigation {adjacentPosts} /> 47 - </div> 48 - {:else} 49 - <NotFoundMessage /> 50 - {/if}
-20
src/routes/blog/[rkey]/+page.ts
··· 1 - export const prerender = false; 2 - 3 - export const load = async ({ parent, params }) => { 4 - const { getPost, profile, getAdjacentPosts } = await parent(); 5 - const post = getPost(params.rkey); 6 - 7 - if (!post) return { status: 404 }; 8 - 9 - // Get adjacent posts for navigation 10 - const adjacentPosts = getAdjacentPosts(params.rkey); 11 - 12 - return { 13 - post, 14 - rkey: params.rkey, 15 - posts: new Map([[params.rkey, post]]), 16 - profile, 17 - adjacentPosts, 18 - getAdjacentPosts: () => adjacentPosts, 19 - }; 20 - };
-133
src/routes/blog/rss/+server.ts
··· 1 - import type { RequestHandler } from "./$types"; 2 - import { dev } from "$app/environment"; 3 - import { parse } from "$lib/parser"; 4 - import type { MarkdownPost } from "$components/shared"; 5 - import { getProfile } from "$components/profile/profile"; // Import getProfile 6 - 7 - export const GET: RequestHandler = async ({ url, fetch }) => { 8 - try { 9 - // Use getProfile to get profile data 10 - const profileData = await getProfile(fetch); 11 - 12 - const did = profileData.did; 13 - const pdsUrl = profileData.pds; 14 - 15 - if (!pdsUrl) throw new Error("Could not find PDS URL"); 16 - 17 - // Get blog posts 18 - const postsResponse = await fetch( 19 - `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=com.whtwnd.blog.entry` 20 - ); 21 - if (!postsResponse.ok) 22 - throw new Error(`Posts fetch failed: ${postsResponse.status}`); 23 - const postsData = await postsResponse.json(); 24 - 25 - // Process posts 26 - const mdposts: Map<string, MarkdownPost> = new Map(); 27 - for (const data of postsData.records) { 28 - const matches = data.uri.split("/"); 29 - const rkey = matches[matches.length - 1]; 30 - const record = data.value; 31 - 32 - if ( 33 - matches && 34 - matches.length === 5 && 35 - record && 36 - (record.visibility === "public" || !record.visibility) 37 - ) { 38 - mdposts.set(rkey, { 39 - title: record.title, 40 - createdAt: new Date(record.createdAt), 41 - mdcontent: record.content, 42 - rkey, 43 - }); 44 - } 45 - } 46 - 47 - // Parse markdown posts to HTML 48 - const posts = await parse(mdposts); 49 - 50 - // Sort posts by date (newest first) 51 - const sortedPosts = Array.from(posts.values()).sort( 52 - (a, b) => b.createdAt.getTime() - a.createdAt.getTime() 53 - ); 54 - 55 - // Build the RSS XML 56 - const baseUrl = dev ? url.origin : "https://example.com"; // Update with your production domain 57 - const rssXml = `<?xml version="1.0" encoding="UTF-8" ?> 58 - <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"> 59 - <channel> 60 - <title>Blog - Site Name</title> 61 - <description>Keywords</description> 62 - <link>${baseUrl}/blog</link> 63 - <atom:link href="${baseUrl}/blog/rss" rel="self" type="application/rss+xml" /> 64 - <image> 65 - ${baseUrl ? `<url>${baseUrl}/embed/blog.png</url>` : ''} 66 - <title>Blog - Site Name</title> 67 - <link>${baseUrl}/blog</link> 68 - </image> 69 - <lastBuildDate>${new Date().toUTCString()}</lastBuildDate> 70 - ${sortedPosts 71 - .map( 72 - (post) => ` 73 - <item> 74 - <title>${escapeXml(post.title)}</title> 75 - <link>${baseUrl}/blog/${post.rkey}</link> 76 - <guid isPermaLink="true">${baseUrl}/blog/${post.rkey}</guid> 77 - <pubDate>${new Date(post.createdAt).toUTCString()}</pubDate> 78 - <description><![CDATA[${post.excerpt || ""}]]></description> 79 - <content:encoded><![CDATA[${post.content || ""}]]></content:encoded> 80 - <author>${profileData.displayName || profileData.handle} (${ 81 - profileData.handle 82 - })</author> 83 - ${baseUrl ? `<media:content url="${baseUrl}/embed/blog.png" medium="image" />` : ''} 84 - </item>` 85 - ) 86 - .join("")} 87 - </channel> 88 - </rss>`; 89 - 90 - return new Response(rssXml, { 91 - headers: { 92 - "Content-Type": "application/xml", 93 - "Cache-Control": "max-age=0, s-maxage=3600", 94 - }, 95 - }); 96 - } catch (error) { 97 - console.error("Error generating RSS feed:", error); 98 - 99 - // Return a minimal valid RSS feed in case of error 100 - return new Response( 101 - `<?xml version="1.0" encoding="UTF-8" ?> 102 - <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> 103 - <channel> 104 - <title>Blog - Site Name</title> 105 - <description>Keywords</description> 106 - <link>${url.origin}/blog</link> 107 - <atom:link href="${ 108 - url.origin 109 - }/blog/rss" rel="self" type="application/rss+xml" /> 110 - <lastBuildDate>${new Date().toUTCString()}</lastBuildDate> 111 - <!-- Error occurred while generating feed items --> 112 - </channel> 113 - </rss>`, 114 - { 115 - headers: { 116 - "Content-Type": "application/xml", 117 - "Cache-Control": "no-cache", 118 - }, 119 - } 120 - ); 121 - } 122 - }; 123 - 124 - // Helper function to escape XML special characters 125 - function escapeXml(unsafe: string): string { 126 - if (!unsafe) return ""; 127 - return unsafe 128 - .replace(/&/g, "&amp;") 129 - .replace(/</g, "&lt;") 130 - .replace(/>/g, "&gt;") 131 - .replace(/"/g, "&quot;") 132 - .replace(/'/g, "&apos;"); 133 - }