@aureuma/svelta 0.0.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.
Files changed (115) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +14 -0
  3. package/.changeset/publish-blogkit.md +5 -0
  4. package/.github/workflows/release.yml +65 -0
  5. package/LICENSE +22 -0
  6. package/README.md +35 -0
  7. package/docs/mintlify-blog-study.md +697 -0
  8. package/package.json +59 -0
  9. package/packages/blogkit/CHANGELOG.md +6 -0
  10. package/packages/blogkit/LICENSE +22 -0
  11. package/packages/blogkit/README.md +93 -0
  12. package/packages/blogkit/dist/components/blog/Avatar.svelte +15 -0
  13. package/packages/blogkit/dist/components/blog/Avatar.svelte.d.ts +22 -0
  14. package/packages/blogkit/dist/components/blog/BackLink.svelte +23 -0
  15. package/packages/blogkit/dist/components/blog/BackLink.svelte.d.ts +21 -0
  16. package/packages/blogkit/dist/components/blog/BlogCard.svelte +37 -0
  17. package/packages/blogkit/dist/components/blog/BlogCard.svelte.d.ts +22 -0
  18. package/packages/blogkit/dist/components/blog/BlogHeroCard.svelte +36 -0
  19. package/packages/blogkit/dist/components/blog/BlogHeroCard.svelte.d.ts +21 -0
  20. package/packages/blogkit/dist/components/blog/Container.svelte +8 -0
  21. package/packages/blogkit/dist/components/blog/Container.svelte.d.ts +29 -0
  22. package/packages/blogkit/dist/components/blog/ImageLightbox.svelte +58 -0
  23. package/packages/blogkit/dist/components/blog/ImageLightbox.svelte.d.ts +24 -0
  24. package/packages/blogkit/dist/components/blog/MorePosts.svelte +15 -0
  25. package/packages/blogkit/dist/components/blog/MorePosts.svelte.d.ts +21 -0
  26. package/packages/blogkit/dist/components/blog/ShareButtons.svelte +113 -0
  27. package/packages/blogkit/dist/components/blog/ShareButtons.svelte.d.ts +23 -0
  28. package/packages/blogkit/dist/components/blog/SummaryCard.svelte +11 -0
  29. package/packages/blogkit/dist/components/blog/SummaryCard.svelte.d.ts +20 -0
  30. package/packages/blogkit/dist/components/blog/TagTabs.svelte +32 -0
  31. package/packages/blogkit/dist/components/blog/TagTabs.svelte.d.ts +23 -0
  32. package/packages/blogkit/dist/index.d.ts +11 -0
  33. package/packages/blogkit/dist/index.js +11 -0
  34. package/packages/blogkit/dist/server/blog.d.ts +39 -0
  35. package/packages/blogkit/dist/server/blog.js +222 -0
  36. package/packages/blogkit/dist/server/index.d.ts +1 -0
  37. package/packages/blogkit/dist/server/index.js +1 -0
  38. package/packages/blogkit/dist/theme/ThemeSwitcher.svelte +34 -0
  39. package/packages/blogkit/dist/theme/ThemeSwitcher.svelte.d.ts +21 -0
  40. package/packages/blogkit/dist/theme/index.d.ts +2 -0
  41. package/packages/blogkit/dist/theme/index.js +2 -0
  42. package/packages/blogkit/dist/theme/store.d.ts +12 -0
  43. package/packages/blogkit/dist/theme/store.js +50 -0
  44. package/packages/blogkit/dist/types/blog.d.ts +31 -0
  45. package/packages/blogkit/dist/types/blog.js +1 -0
  46. package/packages/blogkit/package.json +66 -0
  47. package/packages/blogkit/src/lib/components/blog/Avatar.svelte +15 -0
  48. package/packages/blogkit/src/lib/components/blog/BackLink.svelte +23 -0
  49. package/packages/blogkit/src/lib/components/blog/BlogCard.svelte +37 -0
  50. package/packages/blogkit/src/lib/components/blog/BlogHeroCard.svelte +36 -0
  51. package/packages/blogkit/src/lib/components/blog/Container.svelte +8 -0
  52. package/packages/blogkit/src/lib/components/blog/ImageLightbox.svelte +58 -0
  53. package/packages/blogkit/src/lib/components/blog/MorePosts.svelte +15 -0
  54. package/packages/blogkit/src/lib/components/blog/ShareButtons.svelte +113 -0
  55. package/packages/blogkit/src/lib/components/blog/SummaryCard.svelte +11 -0
  56. package/packages/blogkit/src/lib/components/blog/TagTabs.svelte +32 -0
  57. package/packages/blogkit/src/lib/index.ts +15 -0
  58. package/packages/blogkit/src/lib/server/blog.ts +264 -0
  59. package/packages/blogkit/src/lib/server/index.ts +2 -0
  60. package/packages/blogkit/src/lib/theme/ThemeSwitcher.svelte +34 -0
  61. package/packages/blogkit/src/lib/theme/index.ts +3 -0
  62. package/packages/blogkit/src/lib/theme/store.ts +64 -0
  63. package/packages/blogkit/src/lib/types/blog.ts +36 -0
  64. package/packages/blogkit/svelte.config.js +8 -0
  65. package/packages/blogkit/tsconfig.json +5 -0
  66. package/playwright.config.ts +24 -0
  67. package/postcss.config.cjs +6 -0
  68. package/src/app.css +146 -0
  69. package/src/app.d.ts +13 -0
  70. package/src/app.html +26 -0
  71. package/src/content/blog/ai-summary-cards-with-frontmatter.md +32 -0
  72. package/src/content/blog/announcing-svelta-blog.md +19 -0
  73. package/src/content/blog/best-practices-ship-with-checklists.md +26 -0
  74. package/src/content/blog/building-a-mintlify-inspired-blog.md +49 -0
  75. package/src/content/blog/design-tokens-that-scale.md +47 -0
  76. package/src/content/blog/for-founders-why-speed-matters.md +23 -0
  77. package/src/content/blog/infinite-scroll-with-intersection-observer.md +37 -0
  78. package/src/content/blog/markdown-kitchen-sink.md +101 -0
  79. package/src/content/blog/markdown-pipeline-mdsvex-shiki.md +39 -0
  80. package/src/content/blog/rss-feeds-that-actually-work.md +25 -0
  81. package/src/content/blog/tag-tabs-and-mobile-fade-masks.md +25 -0
  82. package/src/lib/assets/favicon.svg +1 -0
  83. package/src/lib/components/site/SiteFooter.svelte +24 -0
  84. package/src/lib/components/site/SiteHeader.svelte +36 -0
  85. package/src/lib/content/authors.ts +28 -0
  86. package/src/lib/index.ts +1 -0
  87. package/src/lib/server/blog.ts +22 -0
  88. package/src/lib/server/rss.ts +58 -0
  89. package/src/lib/server/seo.ts +31 -0
  90. package/src/lib/stores/theme.ts +10 -0
  91. package/src/lib/types/blog.ts +1 -0
  92. package/src/routes/+layout.svelte +31 -0
  93. package/src/routes/+page.svelte +44 -0
  94. package/src/routes/blog/+page.server.ts +28 -0
  95. package/src/routes/blog/+page.svelte +122 -0
  96. package/src/routes/blog/[slug]/+page.server.ts +39 -0
  97. package/src/routes/blog/[slug]/+page.svelte +118 -0
  98. package/src/routes/blog/posts.json/+server.ts +32 -0
  99. package/src/routes/feed.xml/+server.ts +21 -0
  100. package/static/blog/authors/alex.svg +13 -0
  101. package/static/blog/authors/maria.svg +13 -0
  102. package/static/blog/authors/shawn.svg +13 -0
  103. package/static/blog/covers/ai-summary.svg +38 -0
  104. package/static/blog/covers/design-tokens.svg +37 -0
  105. package/static/blog/covers/infinite-scroll.svg +38 -0
  106. package/static/blog/covers/kitchen-sink.svg +36 -0
  107. package/static/blog/covers/markdown-pipeline.svg +41 -0
  108. package/static/blog/covers/mintlify-style.svg +35 -0
  109. package/static/blog/covers/rss.svg +34 -0
  110. package/static/robots.txt +3 -0
  111. package/svelte.config.js +70 -0
  112. package/tailwind.config.cjs +133 -0
  113. package/tests/blog.spec.ts +63 -0
  114. package/tsconfig.json +21 -0
  115. package/vite.config.ts +14 -0
@@ -0,0 +1,39 @@
1
+ ---
2
+ title: "A Markdown Pipeline That Looks Like Product UI"
3
+ date: "2026-02-01"
4
+ category: "Engineering"
5
+ author: "alex"
6
+ cover: "/blog/covers/markdown-pipeline.svg"
7
+ tags:
8
+ - "Markdown"
9
+ - "Shiki"
10
+ excerpt: "mdsvex gets us Markdown-in-Svelte; Shiki gets us code blocks that don’t look like blogspot."
11
+ summaryAI: "Use mdsvex with `remark-gfm` (tables), `rehype-slug` + `rehype-autolink-headings` (deep links), and `@shikijs/rehype` (syntax highlighting with dual themes). Style `pre.shiki` to match Mintlify’s padding, radius, and line height."
12
+ ---
13
+
14
+ There are two failure modes for Markdown blogs:
15
+
16
+ 1. Everything looks like a generic “prose” template.
17
+ 2. Code blocks are unreadable in either light or dark mode.
18
+
19
+ ## Tables (GFM)
20
+
21
+ | Plugin | Why |
22
+ | --- | --- |
23
+ | `remark-gfm` | tables, strikethrough, task lists |
24
+
25
+ ## Headings you can link to
26
+
27
+ We add `id`s and autolink headings so every section is shareable.
28
+
29
+ ## Shiki, dual theme
30
+
31
+ Mintlify-style code blocks use different themes in light vs dark. We do the same.
32
+
33
+ ```js
34
+ // svelte.config.js
35
+ rehypePlugins: [
36
+ [rehypeShiki, { themes: { light: "one-light", dark: "github-dark-default" } }]
37
+ ]
38
+ ```
39
+
@@ -0,0 +1,25 @@
1
+ ---
2
+ title: "RSS Feeds That Actually Work"
3
+ date: "2026-02-04"
4
+ category: "Best practices"
5
+ author: "shawn"
6
+ cover: "/blog/covers/rss.svg"
7
+ tags:
8
+ - "RSS"
9
+ - "SEO"
10
+ excerpt: "Generate a real RSS 2.0 feed from your Markdown posts and expose it at /feed.xml."
11
+ summaryAI: "Emit RSS 2.0 with an `atom:link rel=\"self\"`, include `title`, `description`, `link`, `guid`, `pubDate`, and `category` for each post, and set `content-type: application/rss+xml`. Keep descriptions short and safe (use the excerpt)."
12
+ ---
13
+
14
+ If you have a blog, you should have RSS. It’s a low-effort, high-trust feature.
15
+
16
+ ## What we generate
17
+
18
+ - RSS 2.0
19
+ - `atom:link` self reference
20
+ - Items with title, link, description, pubDate, and category
21
+
22
+ ## A note on HTML
23
+
24
+ RSS readers vary. Keep the description **plain text** (the excerpt) and let the post page carry the rich formatting.
25
+
@@ -0,0 +1,25 @@
1
+ ---
2
+ title: "Tag Tabs + Mobile Fade Masks"
3
+ date: "2026-02-02"
4
+ category: "Design"
5
+ author: "maria"
6
+ cover: "/blog/covers/design-tokens.svg"
7
+ tags:
8
+ - "Responsive"
9
+ - "UI"
10
+ excerpt: "A horizontally scrollable tag bar on mobile, with fade edges so it feels intentional instead of broken."
11
+ summaryAI: "On small screens, render the tag row as `overflow-x-auto` with hidden scrollbar. Add a `mask-image` gradient to fade the left/right edges. On desktop, disable the mask and allow the pills to sit normally."
12
+ ---
13
+
14
+ Pill tabs are easy on desktop, but mobile needs a different treatment.
15
+
16
+ ## The goal
17
+
18
+ - Horizontal scroll
19
+ - No visible scrollbar
20
+ - Subtle fade at the edges
21
+
22
+ ## CSS mask trick
23
+
24
+ We apply a `mask-image` gradient to the scrolling container, then remove it on larger screens.
25
+
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
@@ -0,0 +1,24 @@
1
+ <script lang="ts">
2
+ import { ThemeSwitcher } from '@aureuma/blogkit/theme';
3
+ import { theme } from '$lib/stores/theme';
4
+ </script>
5
+
6
+ <footer class="border-t border-border-soft/10">
7
+ <div class="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 py-10 md:flex-row md:items-center md:justify-between">
8
+ <div class="text-xs font-mono uppercase tracking-[0.6px] text-text-muted">
9
+ © {new Date().getFullYear()} svelta
10
+ </div>
11
+
12
+ <div class="flex items-center justify-between gap-6 md:justify-end">
13
+ <a
14
+ href="/feed.xml"
15
+ class="inline-flex items-center gap-2 text-xs font-mono uppercase tracking-[0.6px] text-text-sub transition hover:text-text-main"
16
+ >
17
+ <span>RSS</span>
18
+ <span class="inline-block size-1.5 rounded-full bg-brand" aria-hidden="true"></span>
19
+ </a>
20
+
21
+ <ThemeSwitcher controller={theme} />
22
+ </div>
23
+ </div>
24
+ </footer>
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import { page } from '$app/stores';
3
+
4
+ function isActive(path: string) {
5
+ return $page.url.pathname === path || $page.url.pathname.startsWith(`${path}/`);
6
+ }
7
+ </script>
8
+
9
+ <header
10
+ class="sticky top-0 z-50 border-b border-border-soft/10 bg-background-main/75 backdrop-blur supports-[backdrop-filter]:bg-background-main/55"
11
+ >
12
+ <div class="mx-auto flex h-16 w-full max-w-5xl items-center justify-between px-6">
13
+ <a href="/" class="group inline-flex items-center gap-2">
14
+ <span class="text-sm font-semibold tracking-tight">svelta</span>
15
+ <span
16
+ class="rounded-full bg-brand/15 px-2 py-0.5 text-[11px] font-mono uppercase tracking-[0.6px] text-brand"
17
+ >
18
+ Blog
19
+ </span>
20
+ </a>
21
+
22
+ <nav class="flex items-center gap-6 text-sm">
23
+ <a
24
+ href="/"
25
+ class="transition hover:text-text-main {isActive('/') ? 'text-text-main' : 'text-text-sub'}"
26
+ >Home</a
27
+ >
28
+ <a
29
+ href="/blog"
30
+ class="transition hover:text-text-main {isActive('/blog') ? 'text-text-main' : 'text-text-sub'}"
31
+ >Blog</a
32
+ >
33
+ </nav>
34
+ </div>
35
+ </header>
36
+
@@ -0,0 +1,28 @@
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
+ }
@@ -0,0 +1 @@
1
+ // place files you want to import through the `$lib` alias in this folder.
@@ -0,0 +1,22 @@
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;
@@ -0,0 +1,58 @@
1
+ import type { BlogPost } from '$lib/types/blog';
2
+
3
+ function escapeXml(input: string) {
4
+ return input
5
+ .replace(/&/g, '&amp;')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ .replace(/"/g, '&quot;')
9
+ .replace(/'/g, '&apos;');
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
+
@@ -0,0 +1,31 @@
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
+
@@ -0,0 +1,10 @@
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 };
@@ -0,0 +1 @@
1
+ export type { BlogAuthor, BlogCategory, BlogPost, BlogPostFull } from '@aureuma/blogkit';
@@ -0,0 +1,31 @@
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>
@@ -0,0 +1,44 @@
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>
@@ -0,0 +1,28 @@
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
+
@@ -0,0 +1,122 @@
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>
@@ -0,0 +1,39 @@
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
+ };