en un clic
add-blog-post
// Creates a new blog post for the Torrust website. Use when asked to write, add, create, or publish a new blog post or article.
// Creates a new blog post for the Torrust website. Use when asked to write, add, create, or publish a new blog post or article.
Opens a GitHub Pull Request from the current branch, linking it to the related issue when the branch name starts with an issue number.
Creates a new git branch for a GitHub issue following naming conventions with issue number as prefix.
Creates a new Svelte UI component for the Torrust website. Use when asked to add, create, or build a new reusable UI component, widget, or UI element.
Deploys the Torrust website to GitHub Pages. Use when asked to deploy, publish, or release the site, or to set up the deployment workflow.
Runs the full quality check suite for the Torrust website. Use before committing or opening a PR to verify types, linting, and that the build succeeds.
Updates the contributors list on the Torrust website homepage. Use when asked to refresh, update, or sync the contributors list from the Torrust GitHub org.
| name | add-blog-post |
| description | Creates a new blog post for the Torrust website. Use when asked to write, add, create, or publish a new blog post or article. |
Use this skill when asked to create, write, or publish a new blog post on the Torrust website.
Before implementation starts, a GitHub issue must exist for the article and you must create a branch from that issue using the create-issue-branch skill.
Each post lives in its own directory under src/routes/blog/:
src/routes/blog/<post-slug>/
├── metadata.ts # Post metadata (title, date, tags, etc.)
├── +page.server.ts # Server-side data loader (identical boilerplate for every post)
└── +page.svelte # Post content as a Svelte component
Before creating any blog files, create a branch that starts with the issue number:
git checkout -b <issue-number>-<short-description>
Example:
git checkout -b 42-add-nf-conntrack-overflow-article
This ensures the blog post work is traceable to the issue from the start.
mkdir src/routes/blog/<post-slug>
The slug must be kebab-case and must match the slug field in metadata.ts.
metadata.tsexport const metadata = {
title: 'Post Title',
slug: 'post-slug', // must match the directory name exactly
contributor: 'Author Name',
contributorSlug: 'author-slug', // matches a directory under src/routes/contributor/
date: '2024-01-15T12:00:00.000Z', // ISO 8601 format
coverImage: '/images/posts/post-slug/cover.webp',
excerpt: 'Brief description shown in listings and used for SEO.',
tags: ['Rust', 'BitTorrent'] // title-case tags
};
+page.server.ts (copy verbatim)This boilerplate is identical for every post — never modify it:
import { getMetadata } from '$lib/data/metadata';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const slug = url.pathname.split('/').filter(Boolean).pop();
if (!slug) throw new Error('Slug could not be determined.');
const metadata = await getMetadata();
const currentPost = metadata.find((post) => post.slug === slug);
if (!currentPost) throw new Error(`Post not found: ${slug}`);
return { currentPost, allPosts: metadata };
};
+page.svelteUse this template as the starting point. Copy it exactly — do not reorder the script block or change the style section.
Critical: In the
<script>block,$props()and$derived()must come before the imports. Reversing the order causes a Svelte compile error.
Critical: Scss breakpoint mixins are
bp.for-desktop-up,bp.for-tablet-landscape-up, andbp.for-phone-only. Do not usebp.md,bp.lg, or any other names — they do not exist and will cause a Sass compile error.
<script lang="ts">
let { data } = $props();
let currentPost = $derived(data.currentPost);
let allPosts = $derived(data.allPosts);
import BlogPreview from '$lib/components/molecules/BlogPreview.svelte';
import Toc from '$lib/components/atoms/Toc.svelte';
import Post from '$lib/components/organisms/Post.svelte';
import PagesWrapper from '$lib/components/atoms/PagesWrapper.svelte';
import PrevNextPost from '$lib/components/singletons/PrevNextPost.svelte';
import Callout from '$lib/components/molecules/Callout.svelte';
</script>
<Post
title={currentPost.title}
slug={currentPost.slug}
coverImage={currentPost.coverImage}
date={currentPost.date}
tags={currentPost.tags}
excerpt={currentPost.excerpt}
contributor={currentPost.contributor}
contributorSlug={currentPost.contributorSlug}
>
<PagesWrapper>
<div class="wrapper">
<Toc class="toc" />
<div id="toc-contents" class="content-preview">
<!-- Post content goes here -->
<h2 id="introduction">Introduction</h2>
<p>Your intro paragraph...</p>
</div>
</div>
</PagesWrapper>
<PrevNextPost currentPage={currentPost.slug} {allPosts} />
<div class="related-posts-container">
<h2>Related Posts:</h2>
<div class="grid">
{#each data.allPosts.slice(0, 3) as post}
<a href="/blog/{post.slug}">
<BlogPreview post_data={post} />
</a>
{/each}
</div>
</div>
</Post>
<style lang="scss">
@use '$lib/scss/breakpoints.scss' as bp;
/* ── Layout ──────────────────────────────────────────── */
.wrapper {
display: flex;
flex-direction: column;
gap: 2rem;
position: relative;
max-width: 1150px;
}
.content-preview {
flex: 1;
word-break: keep-all;
padding-top: 2rem;
}
/* ── Typography ──────────────────────────────────────── */
h2 {
font-size: 1.8rem;
font-weight: bold;
}
h2:not(:first-of-type) {
padding-top: 1.5rem;
}
h3 {
font-size: 1.5rem;
font-weight: bold;
padding-top: 1.25rem;
}
h4 {
font-size: 1.25rem;
font-weight: bold;
padding-top: 1rem;
}
p {
font-size: 1rem;
padding-top: 1rem;
color: rgba(245, 245, 245, 0.8);
word-break: keep-all;
}
a {
word-break: keep-all;
color: rgba(254, 13, 0, 1);
}
ul,
ol {
display: flex;
flex-direction: column;
list-style-type: disc;
margin: 0;
padding-left: 1.5rem;
word-break: keep-all;
}
ol {
list-style-type: decimal;
}
ul li,
ol li {
margin: 0.25rem 0;
word-break: keep-all;
}
blockquote {
border-left: 4px solid rgba(254, 13, 0, 0.6);
padding: 0.5rem 1rem;
margin: 1rem 0;
color: rgba(245, 245, 245, 0.7);
font-style: italic;
}
code {
background-color: rgba(0, 0, 0, 0.3);
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
pre {
background-color: rgba(0, 0, 0, 0.3);
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
margin: 1rem 0;
code {
background: none;
padding: 0;
}
}
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
th,
td {
padding: 0.6rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.15);
text-align: left;
color: rgba(245, 245, 245, 0.8);
}
th {
background-color: rgba(0, 0, 0, 0.3);
font-weight: 600;
}
.content-preview > p,
.content-preview > ul,
.content-preview > ol,
.content-preview > h2,
.content-preview > h3,
.content-preview > h4 {
margin-bottom: 1rem;
}
/* ── Related posts ───────────────────────────────────── */
.related-posts-container {
display: flex;
flex-direction: column;
color: rgba(245, 245, 245, 0.96);
margin: 0 auto;
text-align: center;
padding-top: 2rem;
@include bp.for-desktop-up {
max-width: 1176px;
}
}
.grid {
padding-top: 3rem;
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 24px;
max-width: 1200px;
margin: 0 auto;
padding-inline: 2.5rem;
@include bp.for-phone-only {
grid-template-columns: 1fr;
}
@include bp.for-tablet-landscape-up {
grid-template-columns: 1fr 1fr;
}
@include bp.for-desktop-up {
grid-template-columns: 1fr 1fr 1fr;
padding-inline: 0;
}
}
/* ── Responsive wrapper + TOC ────────────────────────── */
@include bp.for-desktop-up {
.wrapper {
flex-direction: row;
gap: 4rem;
}
.wrapper :global(.toc) {
position: sticky;
top: 4rem;
height: min-content;
}
.content-preview {
overflow-y: auto;
padding-top: 0rem;
}
}
</style>
The only mixin names defined in $lib/scss/_breakpoints.scss are:
| Mixin | Applies to |
|---|---|
@include bp.for-iphone-se | iPhone SE and similar small screens |
@include bp.for-phone-only | Small phones only |
@include bp.for-tablet-portrait-up | Tablet portrait and above |
@include bp.for-tablet-portrait-down | Below tablet portrait |
@include bp.for-tablet-landscape-up | Tablet landscape and above |
@include bp.for-tablet-landscape-down | Below tablet landscape |
@include bp.for-desktop-up | Desktop and above |
Never invent names like bp.md, bp.lg, bp.sm — they will cause a Sass compile error and a 500 page load error.
Place the cover image under static/images/posts/<post-slug>/. Use an SEO-friendly filename — a descriptive kebab-case name that reflects the post topic, not a generic name like cover.webp:
static/images/posts/<post-slug>/descriptive-kebab-case-name.webp
For example, for a post about submitting trackers to newTrackon:
static/images/posts/submitting-trackers-to-newtrackon/submitting-bittorrent-tracker-to-newtrackon.webp
Update coverImage in metadata.ts to match the exact filename you chose:
coverImage: '/images/posts/<post-slug>/descriptive-kebab-case-name.webp',
/images/posts/<post-slug>/filename.ext<Image src="..." alt="..." /> instead of <img /> for automatic WebP/AVIF optimisationstatic/blogMetadata.json drives the blog listing page and search. Run this after adding or modifying any post:
npx tsx scripts/generateMetadata.ts
Without this step the new post will not appear at /blog.
npm run dev # check the post at http://localhost:5173/blog/<post-slug>
npm run check # TypeScript + Svelte type checking
npm run lint # Prettier + ESLint
<Toc /> auto-generates a table of contents from <h2> and <h3> elements inside the id="toc-contents" div. Every heading must have a matching id attribute:
<h2 id="my-section">My Section</h2><h3 id="my-subsection">My Subsection</h3>
| Component | Import | Usage |
|---|---|---|
<Callout> | $lib/components/molecules/Callout.svelte | <Callout type="info">...</Callout> (types: info, warning, danger) |
<CodeBlock> | $lib/components/molecules/CodeBlock.svelte | Fenced code with syntax highlighting |
<Image> | $lib/components/atoms/Image.svelte | Optimised images (preferred over <img>) |
Look at src/routes/blog/vortex-rust-bittorrent-client-review/ as a complete reference implementation.