Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8ee063b
Add `@next/bundle-analyzer` and `pnpm prettier` script to docs
hasparus Nov 3, 2025
a804bd1
Install zod in docs (same version as elsewhere)
hasparus Nov 3, 2025
2e59252
Add dates to case studies
hasparus Nov 3, 2025
b43dd3f
Unify case study card height
hasparus Nov 3, 2025
912be44
Start showing MoreStoriesSection in prod
hasparus Nov 3, 2025
591724e
Overlap product updates and blog frontmatter types
hasparus Nov 3, 2025
0656f2f
Show case studies and product updates in blog
hasparus Nov 3, 2025
48daade
Expose case studies and product updates in RSS feed
hasparus Nov 3, 2025
2e5e40a
Add zod schemas to author types
hasparus Nov 3, 2025
f4bf439
Add zod schema to blog mdx types
hasparus Nov 3, 2025
9baa925
Add zod schemas to case studies
hasparus Nov 3, 2025
a28f187
Use zod schema in `isCaseStudy`
hasparus Nov 3, 2025
bdff7e5
Use zod to parse blog frontmatter
hasparus Nov 3, 2025
3c8bb4d
Remove old console.log
hasparus Nov 3, 2025
5b6857c
Parse, don't validate
hasparus Nov 3, 2025
fa775e6
Move @types/rss to dependencies to silence ESLint
hasparus Nov 3, 2025
7743f6a
Accept single author as before
hasparus Nov 3, 2025
cac1fb1
Update packages/web/docs/src/app/blog/page.tsx
hasparus Nov 3, 2025
6761aab
Remove unused imports
hasparus Nov 3, 2025
6a0ec30
Add a tag to product updates
hasparus Nov 3, 2025
49bd062
Handle empty author properly
hasparus Nov 3, 2025
c4c6495
Make the author optional in blog card (needed to render for Wealthsim…
hasparus Nov 3, 2025
fd8f2da
Merge branch 'main' into hacky-product-updates-in-feed
hasparus Nov 3, 2025
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
9 changes: 8 additions & 1 deletion packages/web/docs/next.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import withBundleAnalyzer from '@next/bundle-analyzer';
import { withGuildDocs } from '@theguild/components/next.config';

export default withGuildDocs({
let config = withGuildDocs({
output: 'export',
eslint: {
ignoreDuringBuilds: true,
Expand Down Expand Up @@ -334,3 +335,9 @@ export default withGuildDocs({
return config;
},
});

if (process.env.ANALYZE === 'true') {
config = withBundleAnalyzer({ enabled: true })(config);
}

export default config;
8 changes: 6 additions & 2 deletions packages/web/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
},
"scripts": {
"build": "next build && next-sitemap",
"build:analyze": "ANALYZE=true next build",
"dev": "next --turbopack",
"postbuild": "pagefind --site .next/server/app --output-path out/_pagefind",
"prettier": "prettier --cache --write --list-different --ignore-unknown src",
"validate-mdx-links": "pnpx [email protected] --files 'src/**/*.mdx'"
},
"dependencies": {
Expand All @@ -17,6 +19,7 @@
"@radix-ui/react-tabs": "1.1.2",
"@radix-ui/react-tooltip": "1.1.6",
"@theguild/components": "9.11.0",
"@types/rss": "^0.0.32",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"date-fns": "4.1.0",
Expand All @@ -28,14 +31,15 @@
"react-dom": "19.0.0",
"react-icons": "5.4.0",
"rehype-frontmatter-mdx-imports": "0.1.1",
"tailwind-merge": "2.6.0"
"tailwind-merge": "2.6.0",
"zod": "3.25.76"
},
"devDependencies": {
"@mdx-js/typescript-plugin": "^0.0.8",
"@next/bundle-analyzer": "^16.0.0",
"@tailwindcss/typography": "0.5.16",
"@theguild/tailwind-config": "0.6.3",
"@types/react": "18.3.18",
"@types/rss": "^0.0.32",
"next-sitemap": "4.2.3",
"pagefind": "^1.2.0",
"postcss": "8.4.49",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ flowchart
id31["Load total post likes (Post.likeCount)"]
id32["Load comments for each post (Post.comments)"]
id4["Load author for each comments (Comment.author)"]
id5["Load author avataer for each comment author (User.avatar)"]
id5["Load author avatar for each comment author (User.avatar)"]

id1 --> id21
id1 --> id22
Expand All @@ -199,7 +199,7 @@ flowchart
id31["Load total post likes (Post.likeCount)"]
id32["Load comments for each post (Post.comments)"]
id4["Load author for each comments (Comment.author)"]
id5["Load author avataer for each comment author (User.avatar)"]
id5["Load author avatar for each comment author (User.avatar)"]

id1 --> id21
id1 --> id22
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
title: Proven Schema Designs and Best Practices - Part 1
description: From a GraphQL Conf 2025 talk -- proven schema design approaches and patterns.
date: 2025-10-06
authors: [jdolle]
date: 2025-10-06axax
authors: [jdollexaxax]
tags: [graphql]
---

Expand Down
37 changes: 21 additions & 16 deletions packages/web/docs/src/app/blog/blog-types.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
import type { StaticImageData } from 'next/image';
import { AuthorId } from '../../authors';
import { z } from 'zod';
import { AuthorOrId, staticImageDataSchema } from '../../authors';
import { parseSchema } from '../../lib/parse-schema';

Check failure on line 3 in packages/web/docs/src/app/blog/blog-types.ts

View workflow job for this annotation

GitHub Actions / code-style / eslint-and-prettier

'parseSchema' is defined but never used. Allowed unused vars must match /^_/u
import { MdxFile, PageMapItem } from '../../mdx-types';

Check failure on line 4 in packages/web/docs/src/app/blog/blog-types.ts

View workflow job for this annotation

GitHub Actions / code-style / eslint-and-prettier

'PageMapItem' is defined but never used. Allowed unused vars must match /^_/u

export interface BlogFrontmatter {
authors: AuthorId | AuthorId[];
title: string;
date: string;
tags: string | string[];
featured?: boolean;
image?: VideoPath | StaticImageData;
thumbnail?: StaticImageData;
}
export const VideoPath = z
.string()
.regex(/^.+\.(webm|mp4)$/) as z.ZodType<`${string}.${'webm' | 'mp4'}`>;

type VideoPath = `${string}.${'webm' | 'mp4'}`;
export type VideoPath = z.infer<typeof VideoPath>;

export type BlogPostFile = Required<MdxFile<BlogFrontmatter>>;
export const BlogFrontmatter = z.object({
authors: z.array(AuthorOrId),
title: z.string(),
date: z.string(),
tags: z.union([z.string(), z.array(z.string())]),
featured: z.boolean().optional(),
image: z.union([VideoPath, staticImageDataSchema]).optional(),
thumbnail: staticImageDataSchema.optional(),
description: z.string().optional(),
});

export function isBlogPost(item: PageMapItem): item is BlogPostFile {
return item && 'route' in item && 'name' in item && 'frontMatter' in item && !!item.frontMatter;
}
export type BlogFrontmatter = z.infer<typeof BlogFrontmatter>;

export const BlogPostFile = MdxFile(BlogFrontmatter);
export type BlogPostFile = z.infer<typeof BlogPostFile>;
18 changes: 12 additions & 6 deletions packages/web/docs/src/app/blog/components/blog-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,15 @@ export function BlogCard({ post, className, variant, tag }: BlogCardProps) {
const { title, tags } = frontmatter;
const date = new Date(frontmatter.date);

const postAuthors: Author[] = (
typeof frontmatter.authors === 'string'
? [authors[frontmatter.authors as AuthorId]]
: frontmatter.authors.map(author => authors[author as AuthorId])
).filter(Boolean);
const authorsArray = Array.isArray(frontmatter.authors)
? frontmatter.authors
: [frontmatter.authors];

const postAuthors: Author[] = authorsArray
.map((authorId: AuthorId | Author) =>
typeof authorId === 'string' ? authors[authorId] : authorId,
)
.filter(Boolean);

if (postAuthors.length === 0) {
console.error('author not found', frontmatter);
Expand All @@ -44,6 +48,7 @@ export function BlogCard({ post, className, variant, tag }: BlogCardProps) {
className,
)}
href={post.route}
scroll
>
<article
className={cn(
Expand Down Expand Up @@ -74,7 +79,8 @@ export function BlogCard({ post, className, variant, tag }: BlogCardProps) {
<div className="relative size-6">
<Image
src={avatarSrc}
alt={firstAuthor.name}
alt=""
role="presentation"
width={24}
height={24}
className="rounded-full"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { Anchor, cn, Heading } from '@theguild/components';
import { ArrowIcon } from '../../../../components/arrow-icon';
import { useFrontmatter } from '../../../../components/use-frontmatter';
import { ProductUpdateAuthors } from '../../../product-updates/(posts)/product-update-header';
import type { BlogFrontmatter } from '../../blog-types';
import { BlogFrontmatter } from '../../blog-types';
import { BlogTagChip } from '../blog-tag-chip';
import { BlogPostPicture } from './blog-post-picture';

export function BlogPostHeader({ className }: { className?: string }) {
const {
frontmatter: { image, tags, authors, title, date },
} = useFrontmatter<BlogFrontmatter>();
} = useFrontmatter(BlogFrontmatter);

const tag = tags[0];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { BlogFrontmatter, BlogPostFile } from '../../../blog-types';
import { BlogCard } from '../../blog-card';

export function SimilarPostsClient({ sortedPosts }: { sortedPosts: BlogPostFile[] }) {
const { frontmatter } = useFrontmatter<BlogFrontmatter>();
const { frontmatter } = useFrontmatter(BlogFrontmatter);
const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [frontmatter.tags];

const postsToShow = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ArrowIcon, cn, Heading } from '@theguild/components';
import { getPageMap } from '@theguild/components/server';
import { isBlogPost } from '../../../blog-types';
import { parseSchema } from '../../../../../lib/parse-schema';
import { BlogPostFile } from '../../../blog-types';
import { SimilarPostsClient } from './client';

export async function SimilarPosts({ className }: { className?: string }) {
Expand All @@ -11,7 +12,7 @@ export async function SimilarPosts({ className }: { className?: string }) {
// We can optimize this later.
const [_meta, _indexPage, ...pageMap] = await getPageMap('/blog');
const sortedPosts = pageMap
.filter(isBlogPost)
.map(x => parseSchema(x, BlogPostFile))
.sort((a, b) => new Date(b.frontMatter.date).getTime() - new Date(a.frontMatter.date).getTime())
.slice(
0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,24 @@ export function LatestPosts({
<ul className="mt-6 grid grid-cols-1 gap-4 sm:grid sm:grid-cols-2 sm:gap-6 md:mt-16 lg:grid-cols-3 xl:grid-cols-4">
{firstSection}
</ul>
<details className="mt-8 sm:mt-12">
<summary className="bg-beige-200 text-green-1000 border-beige-300 hover:bg-beige-300 hive-focus mx-auto w-fit cursor-pointer select-none list-none rounded-lg border px-4 py-2 hover:border-current dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700 [&::marker]:hidden [[open]>&]:mb-8 [[open]>&]:sm:mb-12">
<span className="[[open]_&]:hidden">Show more</span>
<span className="hidden [[open]_&]:inline">Hide posts</span>
</summary>
{rest.length > 0 && (
<details className="mt-8 sm:mt-12">
<summary className="bg-beige-200 text-green-1000 border-beige-300 hover:bg-beige-300 hive-focus mx-auto w-fit cursor-pointer select-none list-none rounded-lg border px-4 py-2 hover:border-current dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700 [&::marker]:hidden [[open]>&]:mb-8 [[open]>&]:sm:mb-12">
<span className="[[open]_&]:hidden">Show more</span>
<span className="hidden [[open]_&]:inline">Hide posts</span>
</summary>

<ul className="mt-4 grid grid-cols-1 gap-4 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-6 lg:grid-cols-3 xl:grid-cols-4">
{rest.map(post => {
return (
<li key={post.route} className="*:h-full">
<BlogCard post={post} tag={tag} />
</li>
);
})}
</ul>
</details>
<ul className="mt-4 grid grid-cols-1 gap-4 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-6 lg:grid-cols-3 xl:grid-cols-4">
{rest.map(post => {
return (
<li key={post.route} className="*:h-full">
<BlogCard post={post} tag={tag} />
</li>
);
})}
</ul>
</details>
)}
</section>
);
}
80 changes: 52 additions & 28 deletions packages/web/docs/src/app/blog/feed.xml/route.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,50 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import RSS from 'rss';
import { getPageMap } from '@theguild/components/server';
import { AuthorId, authors } from '../../../authors';
import { isBlogPost } from '../blog-types';
import { parseSchema } from '../../../lib/parse-schema';
import { pagesDepthFirst } from '../../../mdx-types';
import { CaseStudyFile } from '../../case-studies/case-study-types';
import { coerceCaseStudyToBlog } from '../../case-studies/coerce-case-studies-to-blogs';
import { BlogFrontmatter, BlogPostFile } from '../blog-types';

function getAuthor(name: string) {
const author = authors[name as AuthorId]?.name;
return author ?? name;
function getAuthor(frontmatterAuthors: BlogFrontmatter['authors']): string {
const first = Array.isArray(frontmatterAuthors) ? frontmatterAuthors[0] : frontmatterAuthors;

if (typeof first === 'string') {
const author = authors[first as AuthorId];
return author ? author.name : 'Unknown Author';
}

return first.name;
}

export const dynamic = 'force-static';
export const config = { runtime: 'edge' };

export async function GET() {
const [_meta, _indexPage, ...pageMap] = await getPageMap('/blog');
const allPosts = pageMap
.filter(isBlogPost)
.map(
item =>
({
title: item.frontMatter.title,
date: new Date(item.frontMatter.date),
url: `https://the-guild.dev/graphql/hive${item.route}`,
description: (item.frontMatter as any).description ?? '',
author: getAuthor(
typeof item.frontMatter.authors === 'string'
? item.frontMatter.authors
: item.frontMatter.authors.at(0)!,
),
categories: Array.isArray(item.frontMatter.tags)
? item.frontMatter.tags
: [item.frontMatter.tags],
}) satisfies RSS.ItemOptions,
)
.sort((a, b) => b.date.getTime() - a.date.getTime());
let allPosts: RSS.ItemOptions[] = [];

const [_meta, _indexPage, ...pages] = await getPageMap('/');
for (const page of pagesDepthFirst(pages)) {
const route = (page && 'route' in page && page.route) || '';
const [dir, name] = route.split('/').filter(Boolean);
if (!name) continue;
switch (dir) {
case 'blog':
case 'product-updates':
allPosts.push(toRssItem(parseSchema(page, BlogPostFile)));
break;
case 'case-studies':
allPosts.push(toRssItem(coerceCaseStudyToBlog(parseSchema(page, CaseStudyFile))));
break;
}
}

if (allPosts.length === 0) {
throw new Error('No blog posts found for RSS feed');
}

allPosts = allPosts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());

const feed = new RSS({
title: 'Hive Blog',
Expand All @@ -49,5 +63,15 @@ export async function GET() {
});
}

export const dynamic = 'force-static';
export const config = { runtime: 'edge' };
function toRssItem(blogPost: BlogPostFile): RSS.ItemOptions {
return {
title: blogPost.frontMatter.title,
date: new Date(blogPost.frontMatter.date),
url: `https://the-guild.dev/graphql/hive${blogPost.route}`,
description: blogPost.frontMatter.description ?? '',
author: getAuthor(blogPost.frontMatter.authors),
categories: Array.isArray(blogPost.frontMatter.tags)
? blogPost.frontMatter.tags
: [blogPost.frontMatter.tags],
};
}
18 changes: 16 additions & 2 deletions packages/web/docs/src/app/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { getPageMap } from '@theguild/components/server';
import { isBlogPost } from './blog-types';
import { parseSchema } from '../../lib/parse-schema';
import { coerceCaseStudiesToBlogs } from '../case-studies/coerce-case-studies-to-blogs';
import { getCaseStudies } from '../case-studies/get-case-studies';
import { BlogPostFile } from './blog-types';
import { NewsletterFormCard } from './components/newsletter-form-card';
import { PostsByTag } from './components/posts-by-tag';
// We can't move this page to `(index)` dir together with `tag` page because Nextra crashes for
Expand All @@ -13,7 +16,18 @@ export const metadata = {

export default async function BlogPage() {
const [_meta, _indexPage, ...pageMap] = await getPageMap('/blog');
const allPosts = pageMap.filter(isBlogPost);
const [, , ...productUpdates] = await getPageMap('/product-updates');

const caseStudies = await getCaseStudies().then(coerceCaseStudiesToBlogs);

const allPosts = pageMap
.map(x => parseSchema(x, BlogPostFile))
.concat(caseStudies)
.concat(
productUpdates
.map(x => parseSchema(x, BlogPostFile))
.map(post => ((post.frontMatter.tags ||= ['Product Update']), post)),
);

return (
<BlogPageLayout>
Expand Down
Loading
Loading