Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Weblog of stuff

- Fix blog sitemap dropping posts beyond the first 100 by paginating through all Ghost Content API pages (2026-07-01)
- Add Core3 per-category risk score breakdown (security, financial, operational, reputational, regulatory) as a dot-meter scorecard on the Core3 rating box, plus sub-scores in the Probability of Loss tooltip (2026-06-18)
- Add autogenerated description box and wide chart layout to chain and stablecoin vault pages, summarising vault count, TVL, top vaults and protocols, and average yield (2026-06-12)
- Add Core3 protocol risk ratings — a rating box on vault detail and protocol pages, plus a Core3 column on the protocols listing (2026-06-10)
Expand Down
100 changes: 100 additions & 0 deletions src/lib/blog/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, test, vi } from 'vitest';

// client.ts reads Ghost credentials at module load; provide dummy values so the
// module can be imported in the unit-test environment. getAllPosts/getPosts hit
// the local proxy endpoint and never touch these directly.
vi.mock('$lib/config', () => ({
ghostConfig: { contentApiKey: 'test-key', apiUrl: 'https://ghost.test' }
}));

import { getAllPosts } from './client';
import type { BlogPostIndexItem } from './schemas';

/**
* Build a single valid blog post index item for the given index.
*/
function makePost(index: number): BlogPostIndexItem {
return {
id: `id-${index}`,
slug: `post-${index}`,
title: `Post ${index}`,
feature_image: `https://example.com/image-${index}.png`,
feature_image_alt: null,
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-02T00:00:00.000Z',
published_at: '2026-01-01T12:00:00.000Z',
excerpt: `Excerpt ${index}`
};
}

/**
* Create a mock proxy `fetch` that serves `total` posts, paginating in blocks of
* 100 exactly like Ghost's Content API (which caps a single response at 100).
*/
function makePaginatedFetch(total: number) {
const pageSize = 100;
const pages = Math.max(1, Math.ceil(total / pageSize));

return vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input), 'http://localhost');
const page = Number(url.searchParams.get('page') ?? '1');

const start = (page - 1) * pageSize;
const posts = Array.from({ length: Math.min(pageSize, total - start) }, (_, i) => makePost(start + i));

const body = {
posts,
meta: {
pagination: {
page,
limit: pageSize,
pages,
total,
next: page < pages ? page + 1 : null,
prev: page > 1 ? page - 1 : null
}
}
};

return new Response(JSON.stringify(body), { status: 200 });
}) as unknown as Fetch;
}

describe('getAllPosts', () => {
test('returns all posts across multiple pages (more than 100)', async () => {
const fetch = makePaginatedFetch(250);

const posts = await getAllPosts(fetch);

// all 250 posts are returned, not just the first 100-post page
expect(posts).toHaveLength(250);
expect(posts.length).toBeGreaterThan(100);

// three pages fetched: 100 + 100 + 50
expect(fetch as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(3);

// slugs are unique and span the full range
expect(new Set(posts.map((p) => p.slug)).size).toBe(250);
expect(posts.at(0)?.slug).toBe('post-0');
expect(posts.at(-1)?.slug).toBe('post-249');
});

test('requests the maximum page size of 100', async () => {
const fetch = makePaginatedFetch(150);

await getAllPosts(fetch);

const firstUrl = new URL(String((fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]), 'http://localhost');
expect(firstUrl.searchParams.get('limit')).toBe('100');
expect(firstUrl.searchParams.get('page')).toBe('1');
});

test('handles a single page of fewer than 100 posts', async () => {
const fetch = makePaginatedFetch(12);

const posts = await getAllPosts(fetch);

expect(posts).toHaveLength(12);
expect(fetch as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(1);
});
});
31 changes: 31 additions & 0 deletions src/lib/blog/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type RequestHandler, error } from '@sveltejs/kit';
import {
type BlogPostDetails,
type BlogPostIndex,
type BlogPostIndexItem,
blogPostResponseSchema,
blogPostIndexSchema,
blogPostIndexItemSchema
Expand Down Expand Up @@ -48,6 +49,36 @@ export async function getPosts(fetch: Fetch, params: BlogIndexParams): Promise<B
return blogPostIndexSchema.parse(await resp.json());
}

// Ghost's Content API caps the number of posts returned in a single response at
// 100, even when `limit=all` is requested. This is the maximum usable page size.
const maxPageSize = 100;

/**
* Fetch every blog post across all pages.
*
* A single request cannot return more than 100 posts (Ghost silently caps
* `limit=all` at 100), so this paginates using the maximum page size and follows
* `meta.pagination.next` until exhausted. Use for consumers that must see the
* complete set of posts, e.g. the sitemap.
*/
export async function getAllPosts(fetch: Fetch): Promise<BlogPostIndexItem[]> {
const posts: BlogPostIndexItem[] = [];
let page = 1;

while (true) {
const { posts: pagePosts, meta } = await getPosts(fetch, { limit: maxPageSize, page });
posts.push(...pagePosts);

const { next } = meta.pagination;
// Stop when Ghost reports no further page. Guard against a malformed
// response that never advances (or moves backwards) to avoid looping forever.
if (next === null || next <= page) break;
page = next;
}

return posts;
}

/**
* A RequestHandler function for proxying blog post API responses from Ghost API
*
Expand Down
4 changes: 2 additions & 2 deletions src/routes/blog/sitemap.xml/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
import type { BlogPostIndexItem } from '$lib/blog/schemas';
import { SitemapStream } from 'sitemap';
import { Readable } from 'stream';
import { getPosts, maxAge } from '$lib/blog/client';
import { getAllPosts, maxAge } from '$lib/blog/client';

export async function GET({ fetch, setHeaders, url }) {
const { posts } = await getPosts(fetch, { limit: 'all' });
const posts = await getAllPosts(fetch);

const stream = new SitemapStream({ hostname: url.origin });
const entries = posts.map((post: BlogPostIndexItem) => ({
Expand Down
Loading