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
11 changes: 11 additions & 0 deletions .agents/skills/pr-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ git worktree add -b <branch-type>/<task-slug> ../<branch-type>/<task-slug> origi
- Add or update focused unit tests for changed logic when applicable.
- Add or update e2e tests for changed user-facing flows when applicable. Use the project guide for required base URLs, credentials, and setup helpers.

## Feature Blog Post

- For public-facing feature work, create or update a Markdown blog post under `apps/web/content/blog/<task-slug>.mdx`.
- The post should explain the shipped feature in user-facing language, include a primary target keyword, include two to five secondary keyword phrases, and link naturally to relevant public pages.
- Include source PR metadata when the post is tied to a PR: `sourcePrNumber` and `sourcePrUrl`.
- Add a `heroImage` and `heroImageAlt` when there is a real screenshot or preview-visible product surface that honestly represents the feature. Store committed blog images under `apps/web/public/blog/`. Leave the hero image empty for internal, config-only, or non-visual posts instead of inventing a fake screenshot.
- Keep the post factual and tied to the implemented feature. Do not add keyword stuffing, claims that are not supported by the product, or roadmap promises.
- Skip the blog post only when the change is internal, security-sensitive, too small to justify a public update, or not user-facing. Record the skip reason in the PR body.
- When a blog post is included, validate the preview `/blog` page, the individual post URL, source PR links, and any hero screenshot rendering during Preview QA.

## Pull Request Loop

- Push the branch to `origin` and create a PR against the configured default branch and GitHub repository.
Expand All @@ -49,6 +59,7 @@ git worktree add -b <branch-type>/<task-slug> ../<branch-type>/<task-slug> origi
- Include an **Original request** section. Quote or accurately summarize the user's request that triggered the work. Do not include secrets, credentials, or unrelated private context.
- Include a **What changed** section with concrete details about the files, behavior, configuration, tests, or workflows changed. Avoid vague summaries such as "updated auth" when the exact change was "set Auth.js session and API JWT max age to 1,209,600 seconds."
- Include a **Reasoning** section explaining why this approach was chosen, what alternatives or tradeoffs mattered, and how the change fits the existing codebase conventions.
- Include a **Blog post** section with the post URL, target keyword, and publish status, or the explicit skip reason.
- Include a **Screenshots / preview evidence** section. Add screenshots to the PR description whenever the work affects UI, preview-visible behavior, or manual QA can demonstrate the requested outcome. Capture the relevant before/after or preview state with Playwright or agent-browser. If the work is non-visual, explicitly say screenshots are not applicable and explain why.
- Include a **Validation** section listing local commands, GitHub checks, preview QA, and SigNoz checks. Mark anything skipped or blocked with the reason.
- Keep the PR body current after every meaningful push, especially after fixing CI, e2e, preview QA, or telemetry issues.
Expand Down
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ EMAIL_REPORTS_ENABLED=0
EMAIL_REPORTS_SCHEDULER_ENABLED=0
EMAIL_REPORTS_DELIVERY_HOUR_LOCAL=8
EMAIL_REPORTS_MAX_ATTEMPTS=3
KEYSTATIC_GITHUB_CLIENT_ID=
KEYSTATIC_GITHUB_CLIENT_SECRET=
KEYSTATIC_SECRET=
OTEL_SERVICE_NAME=xpenser-api
WEB_OTEL_SERVICE_NAME=xpenser-web
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
Expand Down
1 change: 1 addition & 0 deletions apps/web/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ WORKDIR /app
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public
COPY --from=builder /app/apps/web/content ./apps/web/content

RUN mkdir -p /app/apps/web/.next/cache && \
chown -R appuser:appgroup /app/apps/web/.next/cache
Expand Down
31 changes: 31 additions & 0 deletions apps/web/app/api/keystatic/[...params]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { makeRouteHandler } from '@keystatic/next/route-handler';
import keystaticConfig, { isKeystaticAdminEnabled } from '@/keystatic.config';

export async function GET(request: Request): Promise<Response> {
if (!isKeystaticAdminEnabled) {
return notFoundResponse();
}

return createRouteHandler().GET(request);
}

export async function POST(request: Request): Promise<Response> {
if (!isKeystaticAdminEnabled) {
return notFoundResponse();
}

return createRouteHandler().POST(request);
}

function notFoundResponse(): Response {
return new Response('Not found', { status: 404 });
}

function createRouteHandler() {
return makeRouteHandler({
config: keystaticConfig,
clientId: process.env.KEYSTATIC_GITHUB_CLIENT_ID,
clientSecret: process.env.KEYSTATIC_GITHUB_CLIENT_SECRET,
secret: process.env.KEYSTATIC_SECRET
});
}
64 changes: 64 additions & 0 deletions apps/web/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Metadata } from 'next';
import { notFound, redirect } from 'next/navigation';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { BlogPostPage } from '@/components/blog-pages';
import { JsonLdScript } from '@/components/json-ld';
import { mdxComponents } from '@/components/mdx-components';
import {
createBlogPostJsonLd,
createBlogPostMetadata,
getPublishedBlogPost,
getPublishedBlogPosts
} from '@/lib/blog';
import { webConfig } from '@/lib/config';

type BlogPostRouteProps = {
readonly params: Promise<{
readonly slug: string;
}>;
};

export const dynamic = 'force-dynamic';

export async function generateStaticParams() {
const posts = await getPublishedBlogPosts();

return posts.map(post => ({ slug: post.slug }));
}

export async function generateMetadata({
params
}: BlogPostRouteProps): Promise<Metadata> {
const { slug } = await params;
const post = await getPublishedBlogPost(slug);

if (!post) {
return {};
}

return createBlogPostMetadata(post);
}

export default async function BlogPostRoutePage({
params
}: BlogPostRouteProps) {
if (webConfig.singleUser?.enabled) {
redirect('/dashboard');
}

const { slug } = await params;
const post = await getPublishedBlogPost(slug);

if (!post) {
notFound();
}

return (
<>
<JsonLdScript data={createBlogPostJsonLd(post)} />
<BlogPostPage post={post}>
<MDXRemote components={mdxComponents} source={post.content} />
</BlogPostPage>
</>
);
}
24 changes: 24 additions & 0 deletions apps/web/app/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { redirect } from 'next/navigation';
import { BlogIndexPage } from '@/components/blog-pages';
import { JsonLdScript } from '@/components/json-ld';
import { createBlogIndexJsonLd, getPublishedBlogPosts } from '@/lib/blog';
import { webConfig } from '@/lib/config';
import { blogIndexPage, createPublicPageMetadata } from '@/lib/public-site';

export const dynamic = 'force-dynamic';
export const metadata = createPublicPageMetadata(blogIndexPage);

export default async function BlogRoutePage() {
if (webConfig.singleUser?.enabled) {
redirect('/dashboard');
}

const posts = await getPublishedBlogPosts();

return (
<>
<JsonLdScript data={createBlogIndexJsonLd(posts)} />
<BlogIndexPage posts={posts} />
</>
);
}
20 changes: 20 additions & 0 deletions apps/web/app/keystatic/[[...params]]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { makePage } from '@keystatic/next/ui/app';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import keystaticConfig, { isKeystaticAdminEnabled } from '@/keystatic.config';
import { noIndexRobots } from '@/lib/public-site';

const KeystaticPage = makePage(keystaticConfig);

export const metadata: Metadata = {
title: 'xpenser CMS',
robots: noIndexRobots
};

export default function KeystaticAdminPage() {
if (!isKeystaticAdminEnabled) {
notFound();
}

return <KeystaticPage />;
}
30 changes: 21 additions & 9 deletions apps/web/app/metadata-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,26 @@ describe('metadata routes', () => {
);
});

it('serves only public sitemap URLs', () => {
expect(sitemap().map(entry => entry.url)).toEqual([
'https://xpenser.cleverbrush.com/',
'https://xpenser.cleverbrush.com/self-hosted-personal-finance-tracker',
'https://xpenser.cleverbrush.com/open-source-expense-tracker',
'https://xpenser.cleverbrush.com/personal-finance-api-mcp',
'https://xpenser.cleverbrush.com/api-docs'
]);
it('serves only public sitemap URLs', async () => {
const urls = (await sitemap()).map(entry => entry.url);

expect(urls).toHaveLength(60);
expect(urls).toEqual(
expect.arrayContaining([
'https://xpenser.cleverbrush.com/',
'https://xpenser.cleverbrush.com/self-hosted-personal-finance-tracker',
'https://xpenser.cleverbrush.com/open-source-expense-tracker',
'https://xpenser.cleverbrush.com/personal-finance-api-mcp',
'https://xpenser.cleverbrush.com/blog',
'https://xpenser.cleverbrush.com/api-docs',
'https://xpenser.cleverbrush.com/blog/markdown-blog-workflow',
'https://xpenser.cleverbrush.com/blog/dashboard-vendor-view-controls',
'https://xpenser.cleverbrush.com/blog/disable-gtm-in-pr-environments'
])
);
expect(
urls.some(url => new URL(url).pathname.startsWith('/dashboard'))
).toBe(false);
});

it('marks app and auth route groups as noindex', () => {
Expand All @@ -57,6 +69,6 @@ describe('metadata routes', () => {
disallow: '/'
}
});
expect(singleUserSitemap()).toEqual([]);
await expect(singleUserSitemap()).resolves.toEqual([]);
});
});
5 changes: 3 additions & 2 deletions apps/web/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { MetadataRoute } from 'next';
import { getBlogPostSitemap } from '@/lib/blog';
import { webConfig } from '@/lib/config';
import { getPublicSitemap } from '@/lib/public-site';

export default function sitemap(): MetadataRoute.Sitemap {
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
if (webConfig.singleUser?.enabled) {
return [];
}

return getPublicSitemap();
return [...getPublicSitemap(), ...(await getBlogPostSitemap())];
}
69 changes: 69 additions & 0 deletions apps/web/components/blog-pages.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* @vitest-environment jsdom
*/

import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { BlogPost } from '@/lib/blog';
import { BlogIndexPage, BlogPostPage } from './blog-pages';

describe('blog pages', () => {
it('renders the blog index with post links', () => {
render(<BlogIndexPage posts={[blogPost()]} />);

expect(
screen.getByRole('heading', { level: 1, name: 'xpenser blog' })
).toBeTruthy();
expect(
screen.getByRole('link', { name: 'Sample blog post' })
).toHaveProperty('href', 'http://localhost:3000/blog/sample-blog-post');
expect(screen.getByText('sample keyword')).toBeTruthy();
expect(
screen
.getByRole('img', { name: 'Sample dashboard screenshot' })
.getAttribute('src')
).toContain('sample.png');
});

it('renders a blog post shell with metadata and breadcrumbs', () => {
render(
<BlogPostPage post={blogPost()}>
<p>Rendered MDX body</p>
</BlogPostPage>
);

expect(
screen.getByRole('heading', { level: 1, name: 'Sample blog post' })
).toBeTruthy();
expect(screen.getByLabelText('Breadcrumb')).toBeTruthy();
expect(screen.getByText('Published June 27, 2026')).toBeTruthy();
expect(
screen
.getByRole('img', { name: 'Sample dashboard screenshot' })
.getAttribute('src')
).toContain('sample.png');
expect(screen.getByText('Rendered MDX body')).toBeTruthy();
expect(
screen.getByRole('link', { name: /Back to blog/i })
).toHaveProperty('href', 'http://localhost:3000/blog');
});
});

function blogPost(): BlogPost {
return {
content: '# Sample',
description:
'A sample blog post description for the blog component tests.',
draft: false,
heroImage: '/blog/sample.png',
heroImageAlt: 'Sample dashboard screenshot',
keywords: ['open-source expense tracker'],
publishedAt: '2026-06-27',
slug: 'sample-blog-post',
sourcePrNumber: 59,
sourcePrUrl: 'https://github.com/cleverbrush/xpenser/pull/59',
targetKeyword: 'sample keyword',
title: 'Sample blog post',
updatedAt: '2026-06-27'
};
}
Loading
Loading