Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
24 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
759a199
rerun CI
hasparus Nov 5, 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
38 changes: 21 additions & 17 deletions packages/web/docs/src/app/blog/blog-types.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import type { StaticImageData } from 'next/image';
import { AuthorId } from '../../authors';
import { MdxFile, PageMapItem } from '../../mdx-types';
import { z } from 'zod';
import { AuthorOrId, staticImageDataSchema } from '../../authors';
import { MdxFile } from '../../mdx-types';

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.union([z.array(AuthorOrId), 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>
);
}
84 changes: 56 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,54 @@
// 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 'product-updates':
if ('frontMatter' in page && page.frontMatter) {
page.frontMatter.tags ||= ['Product Update'];
}
// eslint-disable-next-line no-fallthrough
case 'blog':
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 +67,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],
};
}
21 changes: 19 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,21 @@ 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'];
return post;
}),
);

return (
<BlogPageLayout>
Expand Down
7 changes: 4 additions & 3 deletions packages/web/docs/src/app/blog/tag/[tag]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextPageProps } 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 { PostsByTag } from '../../components/posts-by-tag';

export async function generateMetadata({ params }: NextPageProps<'tag'>) {
Expand All @@ -11,7 +12,7 @@ export async function generateMetadata({ params }: NextPageProps<'tag'>) {

export default async function BlogTagPage(props: NextPageProps<'tag'>) {
const [_meta, _indexPage, ...pageMap] = await getPageMap('/blog');
const allPosts = pageMap.filter(isBlogPost);
const allPosts = pageMap.map(x => parseSchema(x, BlogPostFile));
const tag = (await props.params).tag;
const posts = allPosts.filter(post => post.frontMatter.tags.includes(tag));

Expand All @@ -20,7 +21,7 @@ export default async function BlogTagPage(props: NextPageProps<'tag'>) {

export async function generateStaticParams() {
const [_meta, _indexPage, ...pageMap] = await getPageMap('/blog');
const allPosts = pageMap.filter(isBlogPost);
const allPosts = pageMap.map(x => parseSchema(x, BlogPostFile));
const tags = allPosts.flatMap(post => post.frontMatter.tags);
const uniqueTags = [...new Set(tags)];
return uniqueTags.map(tag => ({ tag }));
Comment on lines 22 to 27
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the issue in BlogTagPage, generateStaticParams only considers tags from the /blog directory. To ensure all tags from blog posts, product updates, and case studies are statically generated, this function should also aggregate posts from all sources. Also, since the tags property can be a single string, the flatMap logic should be adjusted to handle this correctly to avoid bugs.

Here's a suggested implementation:

import { getCaseStudies } from '../../../../app/case-studies/get-case-studies';
import { coerceCaseStudiesToBlogs } from '../../../../app/case-studies/coerce-case-studies-to-blogs';

// ...

export async function generateStaticParams() {
  const [_meta, _indexPage, ...pageMap] = await getPageMap('/blog');
  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)),
    );
  const tags = allPosts.flatMap(post =>
    Array.isArray(post.frontMatter.tags) ? post.frontMatter.tags : [post.frontMatter.tags],
  );
  const uniqueTags = [...new Set(tags)];
  return uniqueTags.map(tag => ({ tag }));
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually we explicitly don't want to do this. The pages are rendered elsewhere, we just link to them from the blog index page.

Expand Down
Loading
Loading