Add an mdsvex-powered blog to your SvelteKit app. Scaffolds a /blog index with post cards, individual post pages, tag-based filtering, an RSS feed, and an AuthorChip component, along with a handful of example posts so the layout is populated out of the box. Post metadata is typed through a $lib/content module and rendered with a reusable PostCard, giving you a publish-ready content surface without hand-wiring markdown, routing, or feeds.
pre[class*='language-'],
code[class*='language-'] {
color: #d4d4d4;
font-size: 13px;
text-shadow: none;
font-family: Menlo, Monaco, Consolas, 'Andale Mono', 'Ubuntu Mono', 'Courier New', monospace;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*='language-']::selection,
code[class*='language-']::selection,
pre[class*='language-'] *::selection,
code[class*='language-'] *::selection {
text-shadow: none;
background: #264f78;
}
@media print {
pre[class*='language-'],
code[class*='language-'] {
text-shadow: none;
}
}
pre[class*='language-'] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
background: oklch(0.21 0.034 264.665);
}
:not(pre) > code[class*='language-'] {
padding: 0.1em 0.3em;
border-radius: 0.3em;
color: #db4c69;
background: oklch(0.21 0.034 264.665);
}
/*********************************************************
* Tokens
*/
.namespace {
opacity: 0.7;
}
.token.doctype .token.doctype-tag {
color: #569cd6;
}
.token.doctype .token.name {
color: #9cdcfe;
}
.token.comment,
.token.prolog {
color: #6a9955;
}
.token.punctuation,
.language-html .language-css .token.punctuation,
.language-html .language-javascript .token.punctuation {
color: #d4d4d4;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.inserted,
.token.unit {
color: #b5cea8;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.deleted {
color: #ce9178;
}
.language-css .token.string.url {
text-decoration: underline;
}
.token.operator,
.token.entity {
color: #d4d4d4;
}
.token.operator.arrow {
color: #569cd6;
}
.token.atrule {
color: #ce9178;
}
.token.atrule .token.rule {
color: #c586c0;
}
.token.atrule .token.url {
color: #9cdcfe;
}
.token.atrule .token.url .token.function {
color: #dcdcaa;
}
.token.atrule .token.url .token.punctuation {
color: #d4d4d4;
}
.token.keyword {
color: #569cd6;
}
.token.keyword.module,
.token.keyword.control-flow {
color: #c586c0;
}
.token.function,
.token.function .token.maybe-class-name {
color: #dcdcaa;
}
.token.regex {
color: #d16969;
}
.token.important {
color: #569cd6;
}
.token.italic {
font-style: italic;
}
.token.constant {
color: #9cdcfe;
}
.token.class-name,
.token.maybe-class-name {
color: #4ec9b0;
}
.token.console {
color: #9cdcfe;
}
.token.parameter {
color: #9cdcfe;
}
.token.interpolation {
color: #9cdcfe;
}
.token.punctuation.interpolation-punctuation {
color: #569cd6;
}
.token.boolean {
color: #569cd6;
}
.token.property,
.token.variable,
.token.imports .token.maybe-class-name,
.token.exports .token.maybe-class-name {
color: #9cdcfe;
}
.token.selector {
color: #d7ba7d;
}
.token.escape {
color: #d7ba7d;
}
.token.tag {
color: #569cd6;
}
.token.tag .token.punctuation {
color: #808080;
}
.token.cdata {
color: #808080;
}
.token.attr-name {
color: #9cdcfe;
}
.token.attr-value,
.token.attr-value .token.punctuation {
color: #ce9178;
}
.token.attr-value .token.punctuation.attr-equals {
color: #d4d4d4;
}
.token.entity {
color: #569cd6;
}
.token.namespace {
color: #4ec9b0;
}
/*********************************************************
* Language Specific
*/
pre[class*='language-javascript'],
code[class*='language-javascript'],
pre[class*='language-jsx'],
code[class*='language-jsx'],
pre[class*='language-typescript'],
code[class*='language-typescript'],
pre[class*='language-tsx'],
code[class*='language-tsx'] {
color: #9cdcfe;
}
pre[class*='language-css'],
code[class*='language-css'] {
color: #ce9178;
}
pre[class*='language-html'],
code[class*='language-html'] {
color: #d4d4d4;
}
.language-regex .token.anchor {
color: #dcdcaa;
}
.language-html .token.punctuation {
color: #808080;
}
/*********************************************************
* Line highlighting
*/
pre[class*='language-'] > code[class*='language-'] {
position: relative;
z-index: 1;
}
.line-highlight.line-highlight {
background: #f7ebc6;
box-shadow: inset 5px 0 0 #f7d87c;
z-index: 0;
}
<script lang="ts">
import * as Avatar from "$lib/components/ui/avatar";
import type { BlogAuthor } from "$lib/content";
let {
author,
size = "sm",
}: { author: BlogAuthor; size?: "sm" | "default" | "lg" } = $props();
const initials = $derived(
author.name
.split(/\s+/)
.map((part) => part[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase(),
);
</script>
<span class="inline-flex items-center gap-2">
<Avatar.Root {size}>
{#if author.avatar}
<Avatar.Image src={author.avatar} alt={author.name} />
{/if}
<Avatar.Fallback class="text-xs">{initials}</Avatar.Fallback>
</Avatar.Root>
<span class="text-foreground text-sm font-medium">{author.name}</span>
</span>
<script lang="ts">
import * as Card from "$lib/components/ui/card";
import { Badge } from "$lib/components/ui/badge";
import AuthorChip from "./AuthorChip.svelte";
import { formatDate } from "$lib/utils/date";
import type { BlogPost, BlogPostSummary } from "$lib/content";
let { post }: { post: BlogPost | BlogPostSummary } = $props();
</script>
<Card.Root class="hover:ring-foreground/20 h-full transition-shadow w-full">
<Card.Header>
<Card.Title class="text-lg leading-snug">
<a href={`/blog/${post.slug}`} class="hover:underline">
{post.title}
</a>
</Card.Title>
<Card.Description class="line-clamp-2">
{post.description}
</Card.Description>
</Card.Header>
<Card.Content class="flex flex-1 flex-col justify-end gap-3">
{#if post.tags?.length}
<div class="flex flex-wrap gap-1.5">
{#each post.tags as tag (tag)}
<Badge href={`/blog/tags/${tag}`} variant="secondary">{tag}</Badge>
{/each}
</div>
{/if}
<div
class="text-muted-foreground flex items-center justify-between gap-3 text-xs"
>
{#if post.author}
<AuthorChip author={post.author} />
{:else}
<span></span>
{/if}
<span class="whitespace-nowrap">
<time datetime={post.createdDate.toISOString()}
>{formatDate(post.createdDate)}</time
>
<span aria-hidden="true">•</span>
<span>{post.readingTime} min read</span>
</span>
</div>
</Card.Content>
</Card.Root>
import type { Component } from "svelte";
export type BlogAuthor = {
name: string;
avatar?: string;
};
export type BlogMetadata = {
title: string;
description: string;
createdDate: string;
updatedDate?: string;
tags?: string[];
author?: BlogAuthor;
};
export type BlogModules = Record<
string,
{ default: Component; metadata: BlogMetadata }
>;
export type BlogRawModules = Record<string, string>;
export type BlogPost = {
slug: string;
title: string;
description: string;
createdDate: Date;
updatedDate?: Date;
tags?: string[];
author?: BlogAuthor;
readingTime: number;
component: Component;
};
export type BlogPostSummary = Omit<BlogPost, "component">;
const WORDS_PER_MINUTE = 220;
function getBlogModules() {
return import.meta.glob("$lib/content/blog/*.svx", {
eager: true,
}) as BlogModules;
}
function getBlogRawModules() {
return import.meta.glob("$lib/content/blog/*.svx", {
eager: true,
query: "?raw",
import: "default",
}) as BlogRawModules;
}
function stripFrontmatter(raw: string) {
if (!raw.startsWith("---")) return raw;
const end = raw.indexOf("\n---", 3);
if (end === -1) return raw;
return raw.slice(end + 4);
}
function computeReadingTime(raw: string) {
const body = stripFrontmatter(raw);
const words = body.split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.ceil(words / WORDS_PER_MINUTE));
}
export function getBlogPosts(): BlogPost[] {
const modules = getBlogModules();
const rawModules = getBlogRawModules();
const posts = Object.entries(modules).map(([path, module]) => {
const filename = path.split("/").pop() ?? "";
const slug = filename.replace(".svx", "");
const raw = rawModules[path] ?? "";
return {
slug,
title: module.metadata.title,
description: module.metadata.description,
createdDate: new Date(module.metadata.createdDate),
updatedDate: module.metadata.updatedDate
? new Date(module.metadata.updatedDate)
: undefined,
tags: module.metadata.tags,
author: module.metadata.author,
readingTime: computeReadingTime(raw),
component: module.default,
};
});
posts.sort((a, b) => b.createdDate.getTime() - a.createdDate.getTime());
return posts;
}
export function getBlogPost(slug: string) {
const posts = getBlogPosts();
return posts.find((post) => post.slug === slug) ?? null;
}
export function getBlogPostsByTag(tag: string) {
return getBlogPosts().filter((post) => post.tags?.includes(tag));
}
export function getAllTags(): { tag: string; count: number }[] {
const counts = new Map<string, number>();
for (const post of getBlogPosts()) {
for (const tag of post.tags ?? []) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
return [...counts.entries()]
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
}
export function getAdjacentPosts(slug: string): {
prev: BlogPost | null;
next: BlogPost | null;
} {
const posts = getBlogPosts();
const index = posts.findIndex((post) => post.slug === slug);
if (index === -1) return { prev: null, next: null };
return {
next: index > 0 ? posts[index - 1] : null,
prev: index < posts.length - 1 ? posts[index + 1] : null,
};
}
export function getRelatedPosts(slug: string, limit = 3): BlogPost[] {
const posts = getBlogPosts();
const current = posts.find((post) => post.slug === slug);
if (!current?.tags?.length) return [];
const currentTags = new Set(current.tags);
return posts
.filter((post) => post.slug !== slug && post.tags?.length)
.map((post) => ({
post,
overlap: post.tags!.filter((tag) => currentTags.has(tag)).length,
}))
.filter(({ overlap }) => overlap > 0)
.sort(
(a, b) =>
b.overlap - a.overlap ||
b.post.createdDate.getTime() - a.post.createdDate.getTime(),
)
.slice(0, limit)
.map(({ post }) => post);
}
---
title: Design System Notes
description: Small choices that make a design system feel coherent — spacing, radius, and the case for fewer variants.
createdDate: 2025-12-15
tags:
- design
- shadcn
author:
name: Ada Chen
---
We've been cleaning up the design system recently. A few notes from the
pass, mostly for ourselves, partially in case they're useful to anyone
else building on top of shadcn.
## The variants we actually use
Every primitive ships with a dozen variants. In practice our product
leans on two or three per component:
- **Buttons** — `default`, `ghost`, and `outline`. Destructive shows up
rarely enough to be worth the extra click.
- **Badges** — `default` and `secondary`. Tags and counts, nothing else.
- **Cards** — one shape, with optional footer. Multiple card styles
became indistinguishable from "padded div," so we stopped.
If a variant hasn't appeared in a design in six months, it gets deleted.
The point isn't minimalism; it's that every extra variant is a decision
a future designer has to relitigate.
## Spacing
Tailwind's default scale is good. We only locked in one rule: never use
odd gaps. `gap-3` and `gap-5` stay; `gap-2.5` goes. It's a small thing
that quietly keeps vertical rhythm predictable across pages.
## Radius
One radius token, applied everywhere. If a surface needs to feel
different, that's a structural decision — a card, a sheet, a popover —
not a one-off `rounded-md`. We ended up at `0.625rem` as our default
and haven't wanted to change it.
## Dark mode
We treat dark mode as a different palette, not a filter. Every token
has an explicit OKLCH value in both themes. When a component looks
off in dark mode, the fix is almost always a token, not a tweak.
---
title: Getting Started with SvelteKit
description: The smallest useful SvelteKit project, explained end to end — routing, loading data, and the dev loop.
createdDate: 2026-03-12
tags:
- sveltekit
- tutorial
author:
name: Marco Alvarez
---
SvelteKit is the piece that turns Svelte — a component library — into
something you can ship. Routing, server endpoints, data loading, builds,
and deployment adapters all live in one place.
## Creating a project
The fastest path is the CLI. It scaffolds a minimal project, installs
dependencies, and leaves you with a working dev server.
```bash
npx sv create my-app
cd my-app
npm install
npm run dev
```
## Routes are files
A route is a folder under `src/routes` with a `+page.svelte` inside.
Nested folders produce nested URLs. Dynamic segments use `[brackets]`.
```
src/routes/
+page.svelte # /
about/
+page.svelte # /about
blog/
[slug]/
+page.svelte # /blog/:slug
```
## Loading data
A route's `+page.ts` (or `+page.server.ts` for server-only data) exports
a `load` function. Whatever it returns becomes `data` on the page.
```ts
// src/routes/blog/[slug]/+page.ts
import { error } from '@sveltejs/kit';
export const load = async ({ params }) => {
const post = await fetchPost(params.slug);
if (!post) throw error(404, 'Not found');
return { post };
};
```
That's most of SvelteKit. The rest — form actions, hooks, adapters — are
opt-in once you need them.
---
title: A Pragmatic Performance Checklist
description: The small set of things that have given us the biggest performance wins — in rough order of impact.
createdDate: 2025-11-03
tags:
- performance
- tutorial
author:
name: Marco Alvarez
---
We don't have a performance budget so much as a performance allergy.
When something feels slow, we profile, fix the obvious thing, and move
on. The list below is what "the obvious thing" usually is.
> Premature optimization is the root of all evil, but ignoring a 3s
> cold-start isn't optimization — it's negligence.
## The checklist
1. **Measure with real numbers.** Lighthouse and the Network tab answer
most questions. If you're not measuring, you're guessing.
2. **Serve fewer bytes.** Compress images (AVIF or WebP), remove unused
fonts, and audit any dependency over 50KB gzipped.
3. **Defer the non-critical.** Scripts that aren't needed above the
fold can wait. `loading="lazy"` on images, `defer` on scripts, and
dynamic `import()` for interactive widgets.
4. **Cache at the edge.** If the same HTML could serve a thousand users,
it should. Use your adapter's cache headers; don't hand-roll it.
5. **Avoid waterfalls.** If page load depends on three sequential fetches,
collapse them into one endpoint or preload the critical ones.
6. **Trust the framework.** SvelteKit's `load` functions run in parallel
by default and the compiler already strips unused code. Most hand-rolled
cleverness just gets in the way.
## What we stopped worrying about
- Micro-optimizations inside component code — the bundle is small, the
diff is fast. The savings are real but rarely matter.
- Third-party tooling that promises "speed." If it adds a build step,
the benefit needs to be measurable, not theoretical.
## When to go deeper
If the checklist doesn't move the needle, the next step is almost
always a deeper look at the data layer — query plans, N+1 fetches, cold
cache behavior. But you should have exhausted the list above before
getting there. Most slowness is boring, and most fixes are too.
---
title: Welcome to the Blog
description: A quick note on what this space is for and what to expect from us.
createdDate: 2026-04-20
tags:
- announcements
author:
name: Ada Chen
---
Welcome in. This blog is where we write about the things we build, the
decisions behind them, and what we learn when they go sideways.
You can expect a mix of short notes and longer walkthroughs. No schedule,
no filler. If a post exists it's because something was worth writing down.
A few threads you'll see pop up often:
- Product notes on what we're shipping and why
- Practical tutorials for the tools we use every day
- Design and engineering decisions that took more than five minutes
If you'd rather not check back manually, the [RSS feed](/blog/rss.xml)
has every post. Thanks for reading.
---
title: Why We Chose Tailwind v4
description: Notes on moving from a traditional CSS pipeline to Tailwind v4's zero-config, CSS-first setup.
createdDate: 2026-02-18
tags:
- tailwind
- design
author:
name: Ada Chen
---
Tailwind v4 is a meaningful rewrite. The surface API still looks the same
— utility classes on elements — but the compiler, configuration model,
and theming story all changed in ways that made us want to adopt it.
> The point of a design system isn't to ship fewer classes. It's to make
> the next decision easier than the last.
## What changed for us
Three things mattered:
1. **CSS-first config.** `tailwind.config.js` is gone. Tokens live in
`app.css` as real CSS custom properties, which means they also work
outside of Tailwind — SVG fills, inline styles, wherever.
2. **OKLCH colors.** Designers can reason about lightness and chroma
independently. Contrast checks become much easier to eyeball.
3. **Speed.** The new engine compiles fast enough that we stopped
thinking about purge lists. Dev feedback is effectively instant.
## The config we kept
We pinned two things in `app.css`:
```css
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
}
```
Everything else — spacing, typography, breakpoints — falls back to
defaults until we have a reason to override it.
## What we'd tell a team evaluating it
If you're already using Tailwind v3, the migration is mostly a config
move. If you're starting from scratch, v4 removes a pile of decisions
you'd otherwise be making on day one. That alone made it worth it.
---
title: Writing Posts with MDsveX
description: How this blog turns markdown files into real Svelte components — frontmatter, code, and embedded UI.
createdDate: 2026-01-28
updatedDate: 2026-02-02
tags:
- mdsvex
- tutorial
author:
name: Priya Shah
---
Posts on this blog are `.svx` files. Each one is a real Svelte component
at build time, so Markdown and Svelte sit side by side with no glue
code.
## Frontmatter
YAML at the top of a post becomes the `metadata` export. The loader
reads it to build the list view, tag pages, and RSS feed.
```yaml
---
title: Writing Posts with MDsveX
description: A short description shown in previews.
createdDate: 2026-01-28
tags:
- mdsvex
- tutorial
author:
name: Priya Shah
---
```
## Markdown works the way you'd expect
Headings, lists, links, blockquotes, and inline `code` all render.
Fenced code blocks pick up language hints for syntax highlighting when a
plugin is enabled.
```ts
function greet(name: string) {
return `hello, ${name}`;
}
```
## Images
Put images in `static/` and reference them with a root-relative path:
```md

```
## Svelte in your posts
The superpower is that you can drop components straight into a post —
a chart, an interactive demo, a call-to-action card. The rules are the
same as any other Svelte file:
```svelte
<script>
import Callout from '$lib/components/Callout.svelte';
</script>
<Callout>This is a real component inside a markdown post.</Callout>
```
That's the whole pipeline. Write markdown, sprinkle in components when
plain text stops being enough, and the rest is just Svelte.
const formatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
export function formatDate(date: Date) {
return formatter.format(date);
}
<script lang="ts">
import { Badge } from "$lib/components/ui/badge";
import { Separator } from "$lib/components/ui/separator";
import AuthorChip from "$lib/components/blog/AuthorChip.svelte";
import PostCard from "$lib/components/blog/PostCard.svelte";
import { formatDate } from "$lib/utils/date";
import * as Breadcrumb from "$lib/components/ui/breadcrumb";
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
import ArrowRight from "@lucide/svelte/icons/arrow-right";
import { page } from "$app/state";
let { data } = $props();
let breadcrumbs = $derived(page.data.breadcrumbs || []) as {
title: string;
url: string;
}[];
</script>
<div class="flex flex-col gap-6">
<header class="flex flex-col gap-5 border-b border-border pb-8">
<div class="flex flex-col gap-3">
<h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">
{data.blogPost.title}
</h1>
<p class="text-muted-foreground text-lg">
{data.blogPost.description}
</p>
</div>
<div
class="text-muted-foreground flex flex-wrap items-center gap-x-1 gap-y-2 text-sm"
>
{#if data.blogPost.author}
<AuthorChip author={data.blogPost.author} />
<span aria-hidden="true" class="text-muted-foreground/50">•</span>
{/if}
<time datetime={data.blogPost.createdDate.toISOString()}>
{formatDate(data.blogPost.createdDate)}
</time>
{#if data.blogPost.updatedDate}
<span aria-hidden="true" class="text-muted-foreground/50">•</span>
<span>Updated {formatDate(data.blogPost.updatedDate)}</span>
{/if}
<span aria-hidden="true" class="text-muted-foreground/50">•</span>
<span>{data.blogPost.readingTime} min read</span>
</div>
{#if data.blogPost.tags?.length}
<div class="flex flex-wrap gap-1.5">
{#each data.blogPost.tags as tag (tag)}
<Badge href={`/blog/tags/${tag}`} variant="secondary">{tag}</Badge>
{/each}
</div>
{/if}
</header>
<article
class="prose dark:prose-invert max-w-prose prose-headings:mb-0 prose-headings:mt-4"
>
<data.blogPost.component />
</article>
<Separator />
<nav class="grid gap-3 sm:grid-cols-2" aria-label="Post navigation">
{#if data.adjacent.prev}
<a
href={`/blog/${data.adjacent.prev.slug}`}
class="ring-foreground/10 hover:ring-foreground/20 bg-card text-card-foreground group flex flex-col gap-1 rounded-xl p-4 ring-1 transition-shadow"
>
<span
class="text-muted-foreground inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wide"
>
<ArrowLeft class="size-3.5" />
Previous
</span>
<span class="font-medium group-hover:underline"
>{data.adjacent.prev.title}</span
>
</a>
{:else}
<div></div>
{/if}
{#if data.adjacent.next}
<a
href={`/blog/${data.adjacent.next.slug}`}
class="ring-foreground/10 hover:ring-foreground/20 bg-card text-card-foreground group flex flex-col gap-1 rounded-xl p-4 text-right ring-1 transition-shadow sm:col-start-2"
>
<span
class="text-muted-foreground inline-flex items-center justify-end gap-1 text-xs font-medium uppercase tracking-wide"
>
Next
<ArrowRight class="size-3.5" />
</span>
<span class="font-medium group-hover:underline"
>{data.adjacent.next.title}</span
>
</a>
{/if}
</nav>
{#if data.related.length}
<section class="flex flex-col gap-4">
<Separator />
<h2 class="text-xl font-semibold tracking-tight">Related posts</h2>
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.related as post (post.slug)}
<li class="flex">
<PostCard {post} />
</li>
{/each}
</ul>
</section>
{/if}
</div>
import { error } from "@sveltejs/kit";
import { definePageMetaTags } from "svelte-meta-tags";
import {
getAdjacentPosts,
getBlogPost,
getBlogPosts,
getRelatedPosts,
} from "$lib/content";
export const load = async ({ params, parent }) => {
await parent();
const blogPost = getBlogPost(params.slug);
if (blogPost === null) {
throw error(404, "Page not found");
}
const adjacent = getAdjacentPosts(params.slug);
const related = getRelatedPosts(params.slug);
const pageMetaTags = definePageMetaTags({
title: blogPost.title,
description: blogPost.description,
});
const breadcrumbs = [
{ title: "Home", url: "/" },
{ title: "Blog", url: "/blog" },
{ title: blogPost.title, url: `/blog/${blogPost.slug}` },
];
return { blogPost, adjacent, related, breadcrumbs, ...pageMetaTags };
};
export const entries = async () => {
return getBlogPosts().map((post) => ({ slug: post.slug }));
};
<script lang="ts">
import * as Breadcrumb from "$lib/components/ui/breadcrumb";
import { page } from "$app/state";
let { children } = $props();
let breadcrumbs = $derived(page.data.breadcrumbs || []) as {
title: string;
url: string;
}[];
</script>
<section data-role="content">
<Breadcrumb.Root class="mb-6">
<Breadcrumb.List>
{#if breadcrumbs.length > 1}
{#each breadcrumbs.slice(0, -1) as breadcrumb}
<Breadcrumb.Item>
<Breadcrumb.Link href={breadcrumb.url}
>{breadcrumb.title}</Breadcrumb.Link
>
</Breadcrumb.Item>
<Breadcrumb.Separator />
{/each}
{/if}
<Breadcrumb.Page>
{breadcrumbs[breadcrumbs.length - 1].title}
</Breadcrumb.Page>
</Breadcrumb.List>
</Breadcrumb.Root>
{@render children?.()}
</section>
<script lang="ts">
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
import PostCard from "$lib/components/blog/PostCard.svelte";
import Rss from "@lucide/svelte/icons/rss";
let { data } = $props();
</script>
<div class="flex flex-col gap-10">
<header
class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between"
>
<div class="max-w-2xl">
<h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">Blog</h1>
<p class="text-muted-foreground mt-2 text-base">
Product notes, tutorials, and engineering posts from the team.
</p>
</div>
<Button href="/blog/rss.xml" variant="outline" size="sm" class="w-fit">
<Rss class="size-4" />
RSS
</Button>
</header>
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.blogPosts as post (post.slug)}
<li class="flex w-full">
<PostCard {post} />
</li>
{/each}
</ul>
</div>
import { definePageMetaTags } from "svelte-meta-tags";
import { getAllTags, getBlogPosts } from "$lib/content";
export const load = async ({ parent }) => {
await parent();
const blogPosts = getBlogPosts();
const allTags = getAllTags();
const pageMetaTags = definePageMetaTags({
title: "Blog",
description: "Product notes, tutorials, and engineering posts.",
});
const breadcrumbs = [
{ title: "Home", url: "/" },
{ title: "Blog", url: "/blog" },
];
return { blogPosts, allTags, breadcrumbs, ...pageMetaTags };
};
import { getBlogPosts } from "$lib/content";
export const prerender = true;
function escapeXml(value: string) {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
export const GET = async ({ locals }) => {
const { appName, appURL } = locals.meta;
const posts = getBlogPosts();
const siteURL = appURL.replace(/\/$/, "");
const feedURL = `${siteURL}/blog/rss.xml`;
const lastBuild = (
posts[0]?.updatedDate ??
posts[0]?.createdDate ??
new Date()
).toUTCString();
const items = posts
.map((post) => {
const link = `${siteURL}/blog/${post.slug}`;
const pubDate = post.createdDate.toUTCString();
const categories = (post.tags ?? [])
.map((tag) => ` <category>${escapeXml(tag)}</category>`)
.join("\n");
const creator = post.author
? ` <dc:creator>${escapeXml(post.author.name)}</dc:creator>`
: "";
return [
" <item>",
` <title>${escapeXml(post.title)}</title>`,
` <link>${escapeXml(link)}</link>`,
` <guid isPermaLink="true">${escapeXml(link)}</guid>`,
` <description>${escapeXml(post.description)}</description>`,
` <pubDate>${pubDate}</pubDate>`,
creator,
categories,
" </item>",
]
.filter(Boolean)
.join("\n");
})
.join("\n");
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>${escapeXml(appName)}</title>
<link>${escapeXml(`${siteURL}/blog`)}</link>
<atom:link href="${escapeXml(feedURL)}" rel="self" type="application/rss+xml" />
<description>${escapeXml(`Latest posts from ${appName}.`)}</description>
<language>en</language>
<lastBuildDate>${lastBuild}</lastBuildDate>
${items}
</channel>
</rss>
`;
return new Response(xml, {
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "max-age=0, s-maxage=3600",
},
});
};
<script lang="ts">
import PostCard from "$lib/components/blog/PostCard.svelte";
let { data } = $props();
</script>
<div class="flex flex-col gap-8">
<header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">
{data.tag}
</h1>
<p class="text-muted-foreground text-base">
{data.blogPosts.length}
{data.blogPosts.length === 1 ? "post" : "posts"} tagged "{data.tag}".
</p>
</header>
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.blogPosts as post (post.slug)}
<li class="flex">
<PostCard {post} />
</li>
{/each}
</ul>
</div>
import { error } from "@sveltejs/kit";
import { definePageMetaTags } from "svelte-meta-tags";
import { getAllTags, getBlogPostsByTag } from "$lib/content";
export const load = async ({ params, parent }) => {
await parent();
const blogPosts = getBlogPostsByTag(params.tag);
if (blogPosts.length === 0) {
throw error(404, "Tag not found");
}
const pageMetaTags = definePageMetaTags({
title: `Posts tagged "${params.tag}"`,
description: `Blog posts tagged "${params.tag}".`,
});
const breadcrumbs = [
{ title: "Home", url: "/" },
{ title: "Blog", url: "/blog" },
{ title: "Tags", url: "/blog/tags" },
{ title: params.tag, url: `/blog/tags/${params.tag}` },
];
return { blogPosts, tag: params.tag, breadcrumbs, ...pageMetaTags };
};
export const entries = async () => {
return getAllTags().map(({ tag }) => ({ tag }));
};
<script lang="ts">
import { Badge } from "$lib/components/ui/badge";
let { data } = $props();
</script>
<div class="flex flex-col gap-8">
<header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">Tags</h1>
<p class="text-muted-foreground text-base">
{data.allTags.length}
{data.allTags.length === 1 ? "tag" : "tags"} used across the blog.
</p>
</header>
{#if data.allTags.length}
<ul class="flex flex-wrap gap-2">
{#each data.allTags as { tag, count } (tag)}
<li>
<Badge href={`/blog/tags/${tag}`} variant="secondary">
{tag}
<span class="text-muted-foreground ml-0.5">{count}</span>
</Badge>
</li>
{/each}
</ul>
{:else}
<p class="text-muted-foreground">No tags yet.</p>
{/if}
</div>
import { definePageMetaTags } from "svelte-meta-tags";
import { getAllTags } from "$lib/content";
export const load = async ({ parent }) => {
await parent();
const allTags = getAllTags();
const pageMetaTags = definePageMetaTags({
title: "Tags",
description: "All tags used on the blog.",
});
const breadcrumbs = [
{ title: "Home", url: "/" },
{ title: "Blog", url: "/blog" },
{ title: "Tags", url: "/blog/tags" },
];
return { allTags, breadcrumbs, ...pageMetaTags };
};
<script lang="ts">
import { toggleMode } from "mode-watcher";
import * as Navbar from "$lib/components/ui/navbar";
import { Button } from "$lib/components/ui/button";
let { children, data } = $props();
</script>
<div class="min-h-dvh grid grid-rows-[max-content_1fr_max-content] root-layout">
<Navbar.Root>
<Navbar.Brand href="/">
<Navbar.Logo src="/favicon.svg" alt="Logo" />
</Navbar.Brand>
<Navbar.MobileToggle />
<Navbar.List>
<Navbar.Item>
<Button
class="w-full justify-start md:justify-center md:w-auto"
href="/"
variant="ghost"
>
Home
</Button>
</Navbar.Item>
<Navbar.Item>
<Button
class="w-full justify-start md:justify-center md:w-auto"
href="/blog"
variant="ghost"
>
Blog
</Button>
</Navbar.Item>
</Navbar.List>
</Navbar.Root>
{@render children?.()}
<footer class="border-t border-border py-4">
<div
class="mx-auto flex max-w-7xl px-4 flex-col items-center justify-between gap-3 sm:flex-row"
>
<p class="text-muted-foreground text-sm">
© {new Date().getFullYear()}
{data.meta.appName}. All rights reserved.
</p>
<nav class="flex items-center gap-4 text-sm">
<Button onclick={toggleMode} variant="ghost" size="icon">
<Navbar.Mode />
</Button>
<a href="/privacy" class="underline-offset-4 hover:underline">Privacy</a
>
<a href="/terms" class="underline-offset-4 hover:underline">Terms</a>
</nav>
</div>
</footer>
</div>
<style lang="postcss">
@reference "tailwindcss";
.root-layout :global(section[data-role="content"]) {
@apply container mx-auto max-w-7xl px-4 py-8 w-full;
}
</style>
<script lang="ts">
import "../app.css";
import "../css/prism-vsc-dark-plus.css";
import { ModeWatcher } from "mode-watcher";
import { getFlash } from "sveltekit-flash-message";
import { toast } from "svelte-sonner";
import { page } from "$app/state";
import { Toaster } from "$lib/components/ui/sonner";
import { MetaTags, deepMerge } from "svelte-meta-tags";
let { data, children } = $props();
const flash = getFlash(page);
$effect(() => {
if (!$flash || $flash.type !== "toast") {
return;
}
toast.message($flash.message);
$flash = undefined;
});
let metaTags = $derived(deepMerge(data.baseMetaTags, page.data.pageMetaTags));
</script>
<svelte:head>
<title>{data.meta.appName}</title>
</svelte:head>
<MetaTags {...metaTags} />
<ModeWatcher />
<Toaster />
{@render children?.()}
import adapter from "@sveltejs/adapter-auto";
import { mdsvex } from "mdsvex";
/** @type {import('@sveltejs/kit').Config} */
const config = {
extensions: [".svelte", ".svx"],
preprocess: [mdsvex()],
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) =>
filename.split(/[/\\]/).includes("node_modules") ? undefined : true,
},
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(),
},
};
export default config;