@aureuma/svelta 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -2
- package/package.json +35 -3
- package/packages/core/CHANGELOG.md +18 -0
- package/packages/core/README.md +11 -0
- package/packages/{blogkit → core}/dist/index.d.ts +1 -1
- package/packages/core/dist/server/blog.d.ts +106 -0
- package/packages/core/dist/server/blog.js +470 -0
- package/packages/core/dist/server/index.d.ts +1 -0
- package/packages/core/dist/server/index.js +1 -0
- package/packages/{blogkit → core}/dist/theme/store.js +1 -1
- package/packages/{blogkit → core}/dist/types/blog.d.ts +10 -0
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -14
- package/.changeset/publish-blogkit.md +0 -5
- package/.github/workflows/release.yml +0 -65
- package/docs/mintlify-blog-study.md +0 -697
- package/packages/blogkit/CHANGELOG.md +0 -6
- package/packages/blogkit/README.md +0 -93
- package/packages/blogkit/dist/server/blog.d.ts +0 -39
- package/packages/blogkit/dist/server/blog.js +0 -222
- package/packages/blogkit/dist/server/index.d.ts +0 -1
- package/packages/blogkit/dist/server/index.js +0 -1
- package/packages/blogkit/package.json +0 -66
- package/packages/blogkit/src/lib/components/blog/Avatar.svelte +0 -15
- package/packages/blogkit/src/lib/components/blog/BackLink.svelte +0 -23
- package/packages/blogkit/src/lib/components/blog/BlogCard.svelte +0 -37
- package/packages/blogkit/src/lib/components/blog/BlogHeroCard.svelte +0 -36
- package/packages/blogkit/src/lib/components/blog/Container.svelte +0 -8
- package/packages/blogkit/src/lib/components/blog/ImageLightbox.svelte +0 -58
- package/packages/blogkit/src/lib/components/blog/MorePosts.svelte +0 -15
- package/packages/blogkit/src/lib/components/blog/ShareButtons.svelte +0 -113
- package/packages/blogkit/src/lib/components/blog/SummaryCard.svelte +0 -11
- package/packages/blogkit/src/lib/components/blog/TagTabs.svelte +0 -32
- package/packages/blogkit/src/lib/index.ts +0 -15
- package/packages/blogkit/src/lib/server/blog.ts +0 -264
- package/packages/blogkit/src/lib/server/index.ts +0 -2
- package/packages/blogkit/src/lib/theme/ThemeSwitcher.svelte +0 -34
- package/packages/blogkit/src/lib/theme/index.ts +0 -3
- package/packages/blogkit/src/lib/theme/store.ts +0 -64
- package/packages/blogkit/src/lib/types/blog.ts +0 -36
- package/packages/blogkit/svelte.config.js +0 -8
- package/packages/blogkit/tsconfig.json +0 -5
- package/playwright.config.ts +0 -24
- package/postcss.config.cjs +0 -6
- package/src/app.css +0 -146
- package/src/app.d.ts +0 -13
- package/src/app.html +0 -26
- package/src/content/blog/ai-summary-cards-with-frontmatter.md +0 -32
- package/src/content/blog/announcing-svelta-blog.md +0 -19
- package/src/content/blog/best-practices-ship-with-checklists.md +0 -26
- package/src/content/blog/building-a-mintlify-inspired-blog.md +0 -49
- package/src/content/blog/design-tokens-that-scale.md +0 -47
- package/src/content/blog/for-founders-why-speed-matters.md +0 -23
- package/src/content/blog/infinite-scroll-with-intersection-observer.md +0 -37
- package/src/content/blog/markdown-kitchen-sink.md +0 -101
- package/src/content/blog/markdown-pipeline-mdsvex-shiki.md +0 -39
- package/src/content/blog/rss-feeds-that-actually-work.md +0 -25
- package/src/content/blog/tag-tabs-and-mobile-fade-masks.md +0 -25
- package/src/lib/assets/favicon.svg +0 -1
- package/src/lib/components/site/SiteFooter.svelte +0 -24
- package/src/lib/components/site/SiteHeader.svelte +0 -36
- package/src/lib/content/authors.ts +0 -28
- package/src/lib/index.ts +0 -1
- package/src/lib/server/blog.ts +0 -22
- package/src/lib/server/rss.ts +0 -58
- package/src/lib/server/seo.ts +0 -31
- package/src/lib/stores/theme.ts +0 -10
- package/src/lib/types/blog.ts +0 -1
- package/src/routes/+layout.svelte +0 -31
- package/src/routes/+page.svelte +0 -44
- package/src/routes/blog/+page.server.ts +0 -28
- package/src/routes/blog/+page.svelte +0 -122
- package/src/routes/blog/[slug]/+page.server.ts +0 -39
- package/src/routes/blog/[slug]/+page.svelte +0 -118
- package/src/routes/blog/posts.json/+server.ts +0 -32
- package/src/routes/feed.xml/+server.ts +0 -21
- package/static/blog/authors/alex.svg +0 -13
- package/static/blog/authors/maria.svg +0 -13
- package/static/blog/authors/shawn.svg +0 -13
- package/static/blog/covers/ai-summary.svg +0 -38
- package/static/blog/covers/design-tokens.svg +0 -37
- package/static/blog/covers/infinite-scroll.svg +0 -38
- package/static/blog/covers/kitchen-sink.svg +0 -36
- package/static/blog/covers/markdown-pipeline.svg +0 -41
- package/static/blog/covers/mintlify-style.svg +0 -35
- package/static/blog/covers/rss.svg +0 -34
- package/static/robots.txt +0 -3
- package/svelte.config.js +0 -70
- package/tailwind.config.cjs +0 -133
- package/tests/blog.spec.ts +0 -63
- package/tsconfig.json +0 -21
- package/vite.config.ts +0 -14
- /package/packages/{blogkit → core}/LICENSE +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/Avatar.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/Avatar.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/BackLink.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/BackLink.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/BlogCard.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/BlogCard.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/BlogHeroCard.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/BlogHeroCard.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/Container.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/Container.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/ImageLightbox.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/ImageLightbox.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/MorePosts.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/MorePosts.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/ShareButtons.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/ShareButtons.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/SummaryCard.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/SummaryCard.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/TagTabs.svelte +0 -0
- /package/packages/{blogkit → core}/dist/components/blog/TagTabs.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/index.js +0 -0
- /package/packages/{blogkit → core}/dist/theme/ThemeSwitcher.svelte +0 -0
- /package/packages/{blogkit → core}/dist/theme/ThemeSwitcher.svelte.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/theme/index.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/theme/index.js +0 -0
- /package/packages/{blogkit → core}/dist/theme/store.d.ts +0 -0
- /package/packages/{blogkit → core}/dist/types/blog.js +0 -0
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import type { BlogAuthor } from '$lib/types/blog';
|
|
2
|
-
|
|
3
|
-
export const AUTHORS: Record<string, BlogAuthor> = {
|
|
4
|
-
shawn: {
|
|
5
|
-
id: 'shawn',
|
|
6
|
-
name: 'Shawn',
|
|
7
|
-
title: 'Builder at svelta',
|
|
8
|
-
avatar: '/blog/authors/shawn.svg'
|
|
9
|
-
},
|
|
10
|
-
alex: {
|
|
11
|
-
id: 'alex',
|
|
12
|
-
name: 'Alex Kim',
|
|
13
|
-
title: 'Product Engineer',
|
|
14
|
-
avatar: '/blog/authors/alex.svg'
|
|
15
|
-
},
|
|
16
|
-
maria: {
|
|
17
|
-
id: 'maria',
|
|
18
|
-
name: 'Maria Santos',
|
|
19
|
-
title: 'Design Lead',
|
|
20
|
-
avatar: '/blog/authors/maria.svg'
|
|
21
|
-
}
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export function getAuthor(id: string): BlogAuthor {
|
|
25
|
-
const author = AUTHORS[id];
|
|
26
|
-
if (!author) throw new Error(`Unknown author id: ${id}`);
|
|
27
|
-
return author;
|
|
28
|
-
}
|
package/src/lib/index.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
// place files you want to import through the `$lib` alias in this folder.
|
package/src/lib/server/blog.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { createBlog } from '@aureuma/blogkit/server';
|
|
2
|
-
import { getAuthor } from '$lib/content/authors';
|
|
3
|
-
import type { BlogPostFull } from '$lib/types/blog';
|
|
4
|
-
|
|
5
|
-
type CompiledModule = { default: BlogPostFull['component'] };
|
|
6
|
-
|
|
7
|
-
const compiledModules = import.meta.glob('/src/content/blog/*.md') as Record<
|
|
8
|
-
string,
|
|
9
|
-
() => Promise<CompiledModule>
|
|
10
|
-
>;
|
|
11
|
-
const rawModules = import.meta.glob('/src/content/blog/*.md', {
|
|
12
|
-
query: '?raw',
|
|
13
|
-
import: 'default'
|
|
14
|
-
}) as Record<string, () => Promise<string>>;
|
|
15
|
-
|
|
16
|
-
export const blog = createBlog({
|
|
17
|
-
compiledModules,
|
|
18
|
-
rawModules,
|
|
19
|
-
getAuthor
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
export const { getAllPosts, getAllPostsFull, getPostBySlug, getCategories, pickHero } = blog;
|
package/src/lib/server/rss.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import type { BlogPost } from '$lib/types/blog';
|
|
2
|
-
|
|
3
|
-
function escapeXml(input: string) {
|
|
4
|
-
return input
|
|
5
|
-
.replace(/&/g, '&')
|
|
6
|
-
.replace(/</g, '<')
|
|
7
|
-
.replace(/>/g, '>')
|
|
8
|
-
.replace(/"/g, '"')
|
|
9
|
-
.replace(/'/g, ''');
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function toPubDate(iso: string) {
|
|
13
|
-
const d = /^\d{4}-\d{2}-\d{2}$/.test(iso) ? new Date(`${iso}T00:00:00Z`) : new Date(iso);
|
|
14
|
-
return d.toUTCString();
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function buildRss(opts: {
|
|
18
|
-
baseUrl: URL;
|
|
19
|
-
posts: BlogPost[];
|
|
20
|
-
siteTitle: string;
|
|
21
|
-
description: string;
|
|
22
|
-
maxItems?: number;
|
|
23
|
-
}) {
|
|
24
|
-
const siteLink = new URL('/blog', opts.baseUrl).toString();
|
|
25
|
-
const selfLink = new URL('/feed.xml', opts.baseUrl).toString();
|
|
26
|
-
const maxItems = Math.max(1, Math.min(200, opts.maxItems ?? 50));
|
|
27
|
-
|
|
28
|
-
const items = opts.posts
|
|
29
|
-
.slice(0, maxItems)
|
|
30
|
-
.map((post) => {
|
|
31
|
-
const link = new URL(`/blog/${post.slug}`, opts.baseUrl).toString();
|
|
32
|
-
return [
|
|
33
|
-
'<item>',
|
|
34
|
-
`<title>${escapeXml(post.title)}</title>`,
|
|
35
|
-
`<description>${escapeXml(post.excerpt)}</description>`,
|
|
36
|
-
`<link>${escapeXml(link)}</link>`,
|
|
37
|
-
`<guid isPermaLink="true">${escapeXml(link)}</guid>`,
|
|
38
|
-
`<pubDate>${escapeXml(toPubDate(post.date))}</pubDate>`,
|
|
39
|
-
`<category>${escapeXml(post.category.label)}</category>`,
|
|
40
|
-
`<author>${escapeXml(post.author.name)}</author>`,
|
|
41
|
-
'</item>'
|
|
42
|
-
].join('');
|
|
43
|
-
})
|
|
44
|
-
.join('');
|
|
45
|
-
|
|
46
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
47
|
-
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
48
|
-
<channel>
|
|
49
|
-
<title>${escapeXml(opts.siteTitle)}</title>
|
|
50
|
-
<link>${escapeXml(siteLink)}</link>
|
|
51
|
-
<atom:link href="${escapeXml(selfLink)}" rel="self" type="application/rss+xml" />
|
|
52
|
-
<description>${escapeXml(opts.description)}</description>
|
|
53
|
-
<language>en-us</language>
|
|
54
|
-
${items}
|
|
55
|
-
</channel>
|
|
56
|
-
</rss>`;
|
|
57
|
-
}
|
|
58
|
-
|
package/src/lib/server/seo.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { BlogPost } from '$lib/types/blog';
|
|
2
|
-
|
|
3
|
-
export type PostSeo = {
|
|
4
|
-
title: string;
|
|
5
|
-
description: string;
|
|
6
|
-
canonicalUrl: string;
|
|
7
|
-
og: {
|
|
8
|
-
title: string;
|
|
9
|
-
description: string;
|
|
10
|
-
type: 'article';
|
|
11
|
-
url: string;
|
|
12
|
-
};
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export function buildPostSeo(post: BlogPost, canonicalUrl: string): PostSeo {
|
|
16
|
-
const title = `${post.title} | svelta Blog`;
|
|
17
|
-
const description = post.excerpt;
|
|
18
|
-
|
|
19
|
-
return {
|
|
20
|
-
title,
|
|
21
|
-
description,
|
|
22
|
-
canonicalUrl,
|
|
23
|
-
og: {
|
|
24
|
-
title: post.title,
|
|
25
|
-
description,
|
|
26
|
-
type: 'article',
|
|
27
|
-
url: canonicalUrl
|
|
28
|
-
}
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
package/src/lib/stores/theme.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { createThemeController, type ThemeController, type ThemeMode } from '@aureuma/blogkit/theme';
|
|
2
|
-
|
|
3
|
-
// Keep the storage key stable for this app (also used in `src/app.html`).
|
|
4
|
-
export const theme: ThemeController = createThemeController({ storageKey: 'svelta-theme' });
|
|
5
|
-
|
|
6
|
-
export const themeMode = theme.themeMode;
|
|
7
|
-
export const initTheme = theme.initTheme;
|
|
8
|
-
export const setThemeMode = theme.setThemeMode;
|
|
9
|
-
|
|
10
|
-
export type { ThemeMode };
|
package/src/lib/types/blog.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export type { BlogAuthor, BlogCategory, BlogPost, BlogPostFull } from '@aureuma/blogkit';
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import favicon from '$lib/assets/favicon.svg';
|
|
3
|
-
import '@fontsource/inter/400.css';
|
|
4
|
-
import '@fontsource/inter/500.css';
|
|
5
|
-
import '@fontsource/inter/600.css';
|
|
6
|
-
import '@fontsource/geist-mono/400.css';
|
|
7
|
-
import '@fontsource/geist-mono/500.css';
|
|
8
|
-
import '../app.css';
|
|
9
|
-
import SiteFooter from '$lib/components/site/SiteFooter.svelte';
|
|
10
|
-
import SiteHeader from '$lib/components/site/SiteHeader.svelte';
|
|
11
|
-
import { initTheme } from '$lib/stores/theme';
|
|
12
|
-
import { onMount } from 'svelte';
|
|
13
|
-
|
|
14
|
-
let { children } = $props();
|
|
15
|
-
|
|
16
|
-
onMount(() => {
|
|
17
|
-
const cleanup = initTheme();
|
|
18
|
-
return cleanup;
|
|
19
|
-
});
|
|
20
|
-
</script>
|
|
21
|
-
|
|
22
|
-
<svelte:head>
|
|
23
|
-
<link rel="icon" href={favicon} />
|
|
24
|
-
<link rel="alternate" type="application/rss+xml" title="svelta Blog" href="/feed.xml" />
|
|
25
|
-
</svelte:head>
|
|
26
|
-
|
|
27
|
-
<div class="min-h-dvh bg-background-main text-text-main">
|
|
28
|
-
<SiteHeader />
|
|
29
|
-
<main class="flex-1">{@render children()}</main>
|
|
30
|
-
<SiteFooter />
|
|
31
|
-
</div>
|
package/src/routes/+page.svelte
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
<section class="mx-auto w-full max-w-5xl px-6 py-20">
|
|
2
|
-
<div class="max-w-2xl">
|
|
3
|
-
<p class="text-xs font-mono uppercase tracking-[0.6px] text-text-muted">svelta</p>
|
|
4
|
-
<h1 class="mt-3 text-4xl font-semibold leading-[44px] tracking-[-0.8px]">
|
|
5
|
-
A Mintlify-inspired blog system, built in SvelteKit.
|
|
6
|
-
</h1>
|
|
7
|
-
<p class="mt-4 text-base leading-6 text-text-sub">
|
|
8
|
-
Markdown posts, Shiki code highlighting, tag filtering, infinite scroll, author profiles, share
|
|
9
|
-
widgets, AI summary cards, and an RSS feed.
|
|
10
|
-
</p>
|
|
11
|
-
|
|
12
|
-
<div class="mt-8 flex items-center gap-3">
|
|
13
|
-
<a
|
|
14
|
-
href="/blog"
|
|
15
|
-
class="inline-flex items-center justify-center rounded-full bg-text-main px-5 py-2 text-sm font-medium text-text-invert transition hover:opacity-90"
|
|
16
|
-
>
|
|
17
|
-
Go to Blog
|
|
18
|
-
</a>
|
|
19
|
-
<a
|
|
20
|
-
href="/feed.xml"
|
|
21
|
-
class="inline-flex items-center justify-center rounded-full border border-border-soft/15 bg-background-soft px-5 py-2 text-sm font-medium text-text-main transition hover:bg-background-main/60"
|
|
22
|
-
>
|
|
23
|
-
RSS Feed
|
|
24
|
-
</a>
|
|
25
|
-
</div>
|
|
26
|
-
</div>
|
|
27
|
-
|
|
28
|
-
<div class="mt-16 grid grid-cols-1 gap-5 md:grid-cols-2">
|
|
29
|
-
<div class="rounded-3xl border border-border-soft/10 bg-background-soft p-8">
|
|
30
|
-
<p class="text-xs font-mono uppercase tracking-[0.6px] text-text-muted">Design</p>
|
|
31
|
-
<p class="mt-2 text-lg font-medium tracking-tight">Proportions first</p>
|
|
32
|
-
<p class="mt-2 text-sm leading-6 text-text-sub">
|
|
33
|
-
Containers, spacing, and typography scale tuned to match the Mintlify blog’s feel.
|
|
34
|
-
</p>
|
|
35
|
-
</div>
|
|
36
|
-
<div class="rounded-3xl border border-border-soft/10 bg-background-soft p-8">
|
|
37
|
-
<p class="text-xs font-mono uppercase tracking-[0.6px] text-text-muted">Engineering</p>
|
|
38
|
-
<p class="mt-2 text-lg font-medium tracking-tight">Fast content pipeline</p>
|
|
39
|
-
<p class="mt-2 text-sm leading-6 text-text-sub">
|
|
40
|
-
mdsvex + Shiki + reading-time on the server, with incremental loading on the client.
|
|
41
|
-
</p>
|
|
42
|
-
</div>
|
|
43
|
-
</div>
|
|
44
|
-
</section>
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { getAllPosts, getCategories, pickHero } from '$lib/server/blog';
|
|
2
|
-
import type { PageServerLoad } from './$types';
|
|
3
|
-
|
|
4
|
-
const PAGE_SIZE = 8;
|
|
5
|
-
|
|
6
|
-
export const load: PageServerLoad = async ({ url }) => {
|
|
7
|
-
const [all, categories] = await Promise.all([getAllPosts(), getCategories()]);
|
|
8
|
-
const hero = await pickHero(all);
|
|
9
|
-
|
|
10
|
-
const selected = url.searchParams.get('category') ?? '';
|
|
11
|
-
const validSelected = selected && categories.some((c) => c.slug === selected) ? selected : '';
|
|
12
|
-
|
|
13
|
-
const rest = all.filter((p) => p.slug !== hero.slug);
|
|
14
|
-
const filtered = validSelected ? rest.filter((p) => p.category.slug === validSelected) : rest;
|
|
15
|
-
|
|
16
|
-
const initialPosts = filtered.slice(0, PAGE_SIZE);
|
|
17
|
-
|
|
18
|
-
return {
|
|
19
|
-
hero,
|
|
20
|
-
categories,
|
|
21
|
-
selectedCategory: validSelected,
|
|
22
|
-
initialPosts,
|
|
23
|
-
pageSize: PAGE_SIZE,
|
|
24
|
-
hasMore: filtered.length > initialPosts.length,
|
|
25
|
-
total: filtered.length
|
|
26
|
-
};
|
|
27
|
-
};
|
|
28
|
-
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import { goto } from '$app/navigation';
|
|
3
|
-
import { page } from '$app/stores';
|
|
4
|
-
import { BlogCard, BlogHeroCard, Container, TagTabs } from '@aureuma/blogkit';
|
|
5
|
-
import type { BlogPost } from '$lib/types/blog';
|
|
6
|
-
import { onMount } from 'svelte';
|
|
7
|
-
|
|
8
|
-
let { data } = $props<{
|
|
9
|
-
data: {
|
|
10
|
-
hero: BlogPost;
|
|
11
|
-
categories: { label: string; slug: string }[];
|
|
12
|
-
selectedCategory: string;
|
|
13
|
-
initialPosts: BlogPost[];
|
|
14
|
-
pageSize: number;
|
|
15
|
-
hasMore: boolean;
|
|
16
|
-
total: number;
|
|
17
|
-
};
|
|
18
|
-
}>();
|
|
19
|
-
|
|
20
|
-
let posts = $state<BlogPost[]>([]);
|
|
21
|
-
let offset = $state(0);
|
|
22
|
-
let hasMore = $state(false);
|
|
23
|
-
let loading = $state(false);
|
|
24
|
-
let sentinel: HTMLDivElement | null = $state(null);
|
|
25
|
-
|
|
26
|
-
$effect(() => {
|
|
27
|
-
posts = data.initialPosts;
|
|
28
|
-
offset = data.initialPosts.length;
|
|
29
|
-
hasMore = data.hasMore;
|
|
30
|
-
loading = false;
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
function selectCategory(slug: string) {
|
|
34
|
-
const u = new URL($page.url);
|
|
35
|
-
if (!slug) u.searchParams.delete('category');
|
|
36
|
-
else u.searchParams.set('category', slug);
|
|
37
|
-
goto(`${u.pathname}${u.searchParams.toString() ? `?${u.searchParams.toString()}` : ''}`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function loadMore() {
|
|
41
|
-
if (!hasMore || loading) return;
|
|
42
|
-
loading = true;
|
|
43
|
-
|
|
44
|
-
const u = new URL('/blog/posts.json', $page.url.origin);
|
|
45
|
-
u.searchParams.set('offset', String(offset));
|
|
46
|
-
u.searchParams.set('limit', String(data.pageSize));
|
|
47
|
-
if (data.selectedCategory) u.searchParams.set('category', data.selectedCategory);
|
|
48
|
-
|
|
49
|
-
const res = await fetch(u);
|
|
50
|
-
if (!res.ok) {
|
|
51
|
-
loading = false;
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const payload = (await res.json()) as { posts: BlogPost[]; hasMore: boolean };
|
|
56
|
-
posts = [...posts, ...payload.posts];
|
|
57
|
-
offset += payload.posts.length;
|
|
58
|
-
hasMore = payload.hasMore;
|
|
59
|
-
loading = false;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
onMount(() => {
|
|
63
|
-
if (!sentinel) return;
|
|
64
|
-
const io = new IntersectionObserver(
|
|
65
|
-
(entries) => {
|
|
66
|
-
if (entries.some((e) => e.isIntersecting)) loadMore();
|
|
67
|
-
},
|
|
68
|
-
{ rootMargin: '800px 0px' }
|
|
69
|
-
);
|
|
70
|
-
io.observe(sentinel);
|
|
71
|
-
return () => io.disconnect();
|
|
72
|
-
});
|
|
73
|
-
</script>
|
|
74
|
-
|
|
75
|
-
<Container>
|
|
76
|
-
<BlogHeroCard post={data.hero} />
|
|
77
|
-
|
|
78
|
-
<div class="flex items-center justify-between gap-4">
|
|
79
|
-
<div class="min-w-0 flex-1">
|
|
80
|
-
<TagTabs
|
|
81
|
-
categories={data.categories}
|
|
82
|
-
selected={data.selectedCategory}
|
|
83
|
-
onSelect={selectCategory}
|
|
84
|
-
/>
|
|
85
|
-
</div>
|
|
86
|
-
|
|
87
|
-
<a
|
|
88
|
-
href="/feed.xml"
|
|
89
|
-
class="inline-flex size-9 shrink-0 items-center justify-center rounded-full border border-border-soft/10 bg-background-soft text-text-sub transition hover:bg-background-main/60 hover:text-text-main"
|
|
90
|
-
aria-label="RSS feed"
|
|
91
|
-
>
|
|
92
|
-
<svg viewBox="0 0 24 24" class="size-4" aria-hidden="true">
|
|
93
|
-
<path
|
|
94
|
-
fill="currentColor"
|
|
95
|
-
d="M6.18 17.82a2.18 2.18 0 1 1 0 4.36 2.18 2.18 0 0 1 0-4.36ZM2 8.5v3.1c5.7 0 10.4 4.7 10.4 10.4h3.1C15.5 14.6 9.4 8.5 2 8.5Zm0-6v3.1c9.1 0 16.4 7.3 16.4 16.4H22C22 11.2 12.8 2 2 2Z"
|
|
96
|
-
/>
|
|
97
|
-
</svg>
|
|
98
|
-
</a>
|
|
99
|
-
</div>
|
|
100
|
-
|
|
101
|
-
<section class="pb-32 pt-8">
|
|
102
|
-
<div class="grid grid-cols-1 gap-x-5 gap-y-12 md:grid-cols-2">
|
|
103
|
-
{#if posts.length === 0}
|
|
104
|
-
<p class="text-sm leading-6 text-text-sub">No posts in this category yet.</p>
|
|
105
|
-
{:else}
|
|
106
|
-
{#each posts as post (post.slug)}
|
|
107
|
-
<BlogCard post={post} />
|
|
108
|
-
{/each}
|
|
109
|
-
{/if}
|
|
110
|
-
</div>
|
|
111
|
-
|
|
112
|
-
<div class="mt-16 flex items-center justify-center">
|
|
113
|
-
<div bind:this={sentinel} class="h-10 w-full" aria-hidden="true"></div>
|
|
114
|
-
</div>
|
|
115
|
-
|
|
116
|
-
{#if loading}
|
|
117
|
-
<p class="mt-6 text-center text-xs font-mono uppercase tracking-[0.6px] text-text-muted">
|
|
118
|
-
Loading…
|
|
119
|
-
</p>
|
|
120
|
-
{/if}
|
|
121
|
-
</section>
|
|
122
|
-
</Container>
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { getAllPosts, getPostBySlug } from '$lib/server/blog';
|
|
2
|
-
import { buildPostSeo } from '$lib/server/seo';
|
|
3
|
-
import { error } from '@sveltejs/kit';
|
|
4
|
-
import { render } from 'svelte/server';
|
|
5
|
-
import type { PageServerLoad } from './$types';
|
|
6
|
-
|
|
7
|
-
export const load: PageServerLoad = async ({ params, url }) => {
|
|
8
|
-
const found = await getPostBySlug(params.slug);
|
|
9
|
-
if (!found) throw error(404, 'Post not found');
|
|
10
|
-
|
|
11
|
-
const { component, ...post } = found;
|
|
12
|
-
const rendered = render(component);
|
|
13
|
-
|
|
14
|
-
const all = await getAllPosts();
|
|
15
|
-
const candidates = all.filter((p) => p.slug !== post.slug);
|
|
16
|
-
|
|
17
|
-
const sameCategory = candidates.filter((p) => p.category.slug === post.category.slug);
|
|
18
|
-
const morePosts: typeof candidates = [];
|
|
19
|
-
|
|
20
|
-
for (const p of sameCategory) {
|
|
21
|
-
if (morePosts.length >= 2) break;
|
|
22
|
-
morePosts.push(p);
|
|
23
|
-
}
|
|
24
|
-
for (const p of candidates) {
|
|
25
|
-
if (morePosts.length >= 2) break;
|
|
26
|
-
if (morePosts.some((x) => x.slug === p.slug)) continue;
|
|
27
|
-
morePosts.push(p);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const canonicalUrl = new URL(`/blog/${post.slug}`, url).toString();
|
|
31
|
-
|
|
32
|
-
return {
|
|
33
|
-
post,
|
|
34
|
-
contentHtml: rendered.html,
|
|
35
|
-
morePosts,
|
|
36
|
-
canonicalUrl,
|
|
37
|
-
seo: buildPostSeo(post, canonicalUrl)
|
|
38
|
-
};
|
|
39
|
-
};
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import { Avatar, BackLink, Container, ImageLightbox, MorePosts, ShareButtons, SummaryCard } from '@aureuma/blogkit';
|
|
3
|
-
import type { BlogPost } from '$lib/types/blog';
|
|
4
|
-
|
|
5
|
-
let { data } = $props<{
|
|
6
|
-
data: {
|
|
7
|
-
post: BlogPost;
|
|
8
|
-
contentHtml: string;
|
|
9
|
-
morePosts: BlogPost[];
|
|
10
|
-
canonicalUrl: string;
|
|
11
|
-
seo: {
|
|
12
|
-
title: string;
|
|
13
|
-
description: string;
|
|
14
|
-
canonicalUrl: string;
|
|
15
|
-
og: { title: string; description: string; type: 'article'; url: string };
|
|
16
|
-
};
|
|
17
|
-
};
|
|
18
|
-
}>();
|
|
19
|
-
|
|
20
|
-
let lightbox = $state<{ src: string; alt: string } | null>(null);
|
|
21
|
-
let articleEl = $state<HTMLElement | null>(null);
|
|
22
|
-
|
|
23
|
-
$effect(() => {
|
|
24
|
-
// Re-bind image zoom handlers when content changes (e.g. client-side navigation).
|
|
25
|
-
const _ = data.contentHtml;
|
|
26
|
-
if (!articleEl) return;
|
|
27
|
-
|
|
28
|
-
const imgs = Array.from(articleEl.querySelectorAll('img'));
|
|
29
|
-
const onClick = (e: Event) => {
|
|
30
|
-
const img = e.currentTarget as HTMLImageElement;
|
|
31
|
-
lightbox = { src: img.currentSrc || img.src, alt: img.alt || data.post.title };
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
for (const img of imgs) img.addEventListener('click', onClick);
|
|
35
|
-
return () => {
|
|
36
|
-
for (const img of imgs) img.removeEventListener('click', onClick);
|
|
37
|
-
};
|
|
38
|
-
});
|
|
39
|
-
</script>
|
|
40
|
-
|
|
41
|
-
<svelte:head>
|
|
42
|
-
<title>{data.seo.title}</title>
|
|
43
|
-
<meta name="description" content={data.seo.description} />
|
|
44
|
-
<link rel="canonical" href={data.seo.canonicalUrl} />
|
|
45
|
-
<meta property="og:title" content={data.seo.og.title} />
|
|
46
|
-
<meta property="og:description" content={data.seo.og.description} />
|
|
47
|
-
<meta property="og:type" content={data.seo.og.type} />
|
|
48
|
-
<meta property="og:url" content={data.seo.og.url} />
|
|
49
|
-
</svelte:head>
|
|
50
|
-
|
|
51
|
-
<Container size="4xl">
|
|
52
|
-
<div class="mt-[4.5rem] pb-[7.5rem]">
|
|
53
|
-
<BackLink />
|
|
54
|
-
|
|
55
|
-
<header class="mt-8 border-b border-border-soft/10 pb-8">
|
|
56
|
-
<div class="flex items-center gap-2 text-xs font-mono uppercase tracking-[0.6px]">
|
|
57
|
-
<span class="text-brand">{data.post.category.label}</span>
|
|
58
|
-
<span class="text-text-muted" aria-hidden="true">/</span>
|
|
59
|
-
<span class="text-text-muted">{data.post.readingTimeLong}</span>
|
|
60
|
-
</div>
|
|
61
|
-
|
|
62
|
-
<h1 class="mt-4 text-4xl font-semibold leading-[44px] tracking-[-0.8px] text-text-main">
|
|
63
|
-
{data.post.title}
|
|
64
|
-
</h1>
|
|
65
|
-
|
|
66
|
-
<p class="mt-4 text-xs font-mono uppercase tracking-[0.6px] text-text-muted">
|
|
67
|
-
{data.post.dateLong}
|
|
68
|
-
</p>
|
|
69
|
-
</header>
|
|
70
|
-
|
|
71
|
-
<div class="mt-10 grid grid-cols-1 gap-x-16 md:grid-cols-[minmax(0,628px)_160px]">
|
|
72
|
-
<div class="min-w-0">
|
|
73
|
-
<div class="overflow-hidden rounded-3xl border border-border-soft/10 bg-background-soft">
|
|
74
|
-
<img src={data.post.cover} alt={data.post.title} class="h-[360px] w-full object-cover" />
|
|
75
|
-
</div>
|
|
76
|
-
|
|
77
|
-
{#if data.post.summaryAI}
|
|
78
|
-
<div class="mt-6">
|
|
79
|
-
<SummaryCard summary={data.post.summaryAI} />
|
|
80
|
-
</div>
|
|
81
|
-
{/if}
|
|
82
|
-
|
|
83
|
-
<div class="mt-8 flex flex-col gap-6 md:hidden">
|
|
84
|
-
<div class="flex items-center gap-3">
|
|
85
|
-
<Avatar src={data.post.author.avatar} alt={data.post.author.name} size={48} />
|
|
86
|
-
<div class="leading-tight">
|
|
87
|
-
<div class="text-sm font-medium tracking-tight text-text-main">
|
|
88
|
-
{data.post.author.name}
|
|
89
|
-
</div>
|
|
90
|
-
<div class="text-xs text-text-muted">{data.post.author.title}</div>
|
|
91
|
-
</div>
|
|
92
|
-
</div>
|
|
93
|
-
<ShareButtons title={data.post.title} url={data.canonicalUrl} testId="blog-share-mobile" />
|
|
94
|
-
</div>
|
|
95
|
-
|
|
96
|
-
<article bind:this={articleEl} class="blog-prose prose mt-10">
|
|
97
|
-
{@html data.contentHtml}
|
|
98
|
-
</article>
|
|
99
|
-
|
|
100
|
-
<MorePosts posts={data.morePosts} />
|
|
101
|
-
</div>
|
|
102
|
-
|
|
103
|
-
<aside class="sticky top-20 hidden self-start md:flex md:flex-col md:gap-8">
|
|
104
|
-
<div class="flex items-center gap-3">
|
|
105
|
-
<Avatar src={data.post.author.avatar} alt={data.post.author.name} size={48} />
|
|
106
|
-
<div class="leading-tight">
|
|
107
|
-
<div class="text-sm font-medium tracking-tight text-text-main">{data.post.author.name}</div>
|
|
108
|
-
<div class="text-xs text-text-muted">{data.post.author.title}</div>
|
|
109
|
-
</div>
|
|
110
|
-
</div>
|
|
111
|
-
|
|
112
|
-
<ShareButtons title={data.post.title} url={data.canonicalUrl} testId="blog-share-desktop" />
|
|
113
|
-
</aside>
|
|
114
|
-
</div>
|
|
115
|
-
</div>
|
|
116
|
-
</Container>
|
|
117
|
-
|
|
118
|
-
<ImageLightbox image={lightbox} onClose={() => (lightbox = null)} />
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { getAllPosts, getCategories, pickHero } from '$lib/server/blog';
|
|
2
|
-
import { json } from '@sveltejs/kit';
|
|
3
|
-
import type { RequestHandler } from './$types';
|
|
4
|
-
|
|
5
|
-
const DEFAULT_LIMIT = 8;
|
|
6
|
-
const MAX_LIMIT = 24;
|
|
7
|
-
|
|
8
|
-
export const GET: RequestHandler = async ({ url }) => {
|
|
9
|
-
const offset = Math.max(0, Number.parseInt(url.searchParams.get('offset') ?? '0', 10) || 0);
|
|
10
|
-
const limitRaw =
|
|
11
|
-
Number.parseInt(url.searchParams.get('limit') ?? String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT;
|
|
12
|
-
const limit = Math.min(MAX_LIMIT, Math.max(1, limitRaw));
|
|
13
|
-
|
|
14
|
-
const [all, categories] = await Promise.all([getAllPosts(), getCategories()]);
|
|
15
|
-
const hero = await pickHero(all);
|
|
16
|
-
|
|
17
|
-
const requestedCategory = url.searchParams.get('category') ?? '';
|
|
18
|
-
const category =
|
|
19
|
-
requestedCategory && categories.some((c) => c.slug === requestedCategory)
|
|
20
|
-
? requestedCategory
|
|
21
|
-
: '';
|
|
22
|
-
|
|
23
|
-
const rest = all.filter((p) => p.slug !== hero.slug);
|
|
24
|
-
const filtered = category ? rest.filter((p) => p.category.slug === category) : rest;
|
|
25
|
-
const posts = filtered.slice(offset, offset + limit);
|
|
26
|
-
|
|
27
|
-
return json({
|
|
28
|
-
posts,
|
|
29
|
-
hasMore: filtered.length > offset + posts.length
|
|
30
|
-
});
|
|
31
|
-
};
|
|
32
|
-
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { getAllPosts } from '$lib/server/blog';
|
|
2
|
-
import { buildRss } from '$lib/server/rss';
|
|
3
|
-
import type { RequestHandler } from './$types';
|
|
4
|
-
|
|
5
|
-
export const GET: RequestHandler = async ({ url }) => {
|
|
6
|
-
const posts = await getAllPosts();
|
|
7
|
-
|
|
8
|
-
const rss = buildRss({
|
|
9
|
-
baseUrl: url,
|
|
10
|
-
posts,
|
|
11
|
-
siteTitle: 'svelta Blog',
|
|
12
|
-
description: 'Engineering, design, and product notes from svelta.'
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
return new Response(rss, {
|
|
16
|
-
headers: {
|
|
17
|
-
'content-type': 'application/rss+xml; charset=utf-8',
|
|
18
|
-
'cache-control': 'max-age=0, s-maxage=3600'
|
|
19
|
-
}
|
|
20
|
-
});
|
|
21
|
-
};
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
|
2
|
-
<defs>
|
|
3
|
-
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
-
<stop offset="0" stop-color="#fb7185"/>
|
|
5
|
-
<stop offset="1" stop-color="#f59e0b"/>
|
|
6
|
-
</linearGradient>
|
|
7
|
-
</defs>
|
|
8
|
-
<rect width="128" height="128" rx="64" fill="url(#g)"/>
|
|
9
|
-
<circle cx="64" cy="58" r="22" fill="rgba(255,255,255,0.23)"/>
|
|
10
|
-
<path d="M26 116c10-22 26-32 38-32s28 10 38 32" fill="rgba(255,255,255,0.23)"/>
|
|
11
|
-
<text x="64" y="76" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="28" font-weight="700" fill="rgba(255,255,255,0.92)">A</text>
|
|
12
|
-
</svg>
|
|
13
|
-
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
|
2
|
-
<defs>
|
|
3
|
-
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
-
<stop offset="0" stop-color="#a78bfa"/>
|
|
5
|
-
<stop offset="1" stop-color="#22c55e"/>
|
|
6
|
-
</linearGradient>
|
|
7
|
-
</defs>
|
|
8
|
-
<rect width="128" height="128" rx="64" fill="url(#g)"/>
|
|
9
|
-
<circle cx="64" cy="58" r="22" fill="rgba(255,255,255,0.23)"/>
|
|
10
|
-
<path d="M26 116c10-22 26-32 38-32s28 10 38 32" fill="rgba(255,255,255,0.23)"/>
|
|
11
|
-
<text x="64" y="76" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="28" font-weight="700" fill="rgba(255,255,255,0.92)">M</text>
|
|
12
|
-
</svg>
|
|
13
|
-
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
|
2
|
-
<defs>
|
|
3
|
-
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
-
<stop offset="0" stop-color="#22c55e"/>
|
|
5
|
-
<stop offset="1" stop-color="#0ea5e9"/>
|
|
6
|
-
</linearGradient>
|
|
7
|
-
</defs>
|
|
8
|
-
<rect width="128" height="128" rx="64" fill="url(#g)"/>
|
|
9
|
-
<circle cx="64" cy="58" r="22" fill="rgba(255,255,255,0.25)"/>
|
|
10
|
-
<path d="M26 116c10-22 26-32 38-32s28 10 38 32" fill="rgba(255,255,255,0.25)"/>
|
|
11
|
-
<text x="64" y="76" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="28" font-weight="700" fill="rgba(255,255,255,0.92)">S</text>
|
|
12
|
-
</svg>
|
|
13
|
-
|