+2
-2
package-lock.json
+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
package.json
-24
src/lib/components/archive/StatsDisplay.svelte
-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
-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
-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
-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
-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
-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
-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>
-4
src/lib/components/post/index.ts
-4
src/lib/components/post/index.ts
-3
src/lib/parser/index.ts
-3
src/lib/parser/index.ts
-34
src/lib/parser/parser.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
-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
-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
-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
-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
-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
-8
src/routes/blog/+layout.ts
-199
src/routes/blog/+page.svelte
-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
-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
-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
-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, "&")
129
-
.replace(/</g, "<")
130
-
.replace(/>/g, ">")
131
-
.replace(/"/g, """)
132
-
.replace(/'/g, "'");
133
-
}