diff --git a/.agents/skills/pr-workflow/SKILL.md b/.agents/skills/pr-workflow/SKILL.md index 2ec4e01..b80d2da 100644 --- a/.agents/skills/pr-workflow/SKILL.md +++ b/.agents/skills/pr-workflow/SKILL.md @@ -41,6 +41,16 @@ git worktree add -b / ..// 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/.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. @@ -49,6 +59,7 @@ git worktree add -b / ..// 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. diff --git a/.env.example b/.env.example index 8cbaa7f..f245b26 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 4cd2474..d307ade 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -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 diff --git a/apps/web/app/api/keystatic/[...params]/route.ts b/apps/web/app/api/keystatic/[...params]/route.ts new file mode 100644 index 0000000..d34840a --- /dev/null +++ b/apps/web/app/api/keystatic/[...params]/route.ts @@ -0,0 +1,31 @@ +import { makeRouteHandler } from '@keystatic/next/route-handler'; +import keystaticConfig, { isKeystaticAdminEnabled } from '@/keystatic.config'; + +export async function GET(request: Request): Promise { + if (!isKeystaticAdminEnabled) { + return notFoundResponse(); + } + + return createRouteHandler().GET(request); +} + +export async function POST(request: Request): Promise { + 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 + }); +} diff --git a/apps/web/app/blog/[slug]/page.tsx b/apps/web/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..1d853b4 --- /dev/null +++ b/apps/web/app/blog/[slug]/page.tsx @@ -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 { + 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 ( + <> + + + + + + ); +} diff --git a/apps/web/app/blog/page.tsx b/apps/web/app/blog/page.tsx new file mode 100644 index 0000000..dfd376a --- /dev/null +++ b/apps/web/app/blog/page.tsx @@ -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 ( + <> + + + + ); +} diff --git a/apps/web/app/keystatic/[[...params]]/page.tsx b/apps/web/app/keystatic/[[...params]]/page.tsx new file mode 100644 index 0000000..3dfd4f2 --- /dev/null +++ b/apps/web/app/keystatic/[[...params]]/page.tsx @@ -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 ; +} diff --git a/apps/web/app/metadata-routes.test.ts b/apps/web/app/metadata-routes.test.ts index 3f58ed0..eb0c617 100644 --- a/apps/web/app/metadata-routes.test.ts +++ b/apps/web/app/metadata-routes.test.ts @@ -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', () => { @@ -57,6 +69,6 @@ describe('metadata routes', () => { disallow: '/' } }); - expect(singleUserSitemap()).toEqual([]); + await expect(singleUserSitemap()).resolves.toEqual([]); }); }); diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts index e4af060..358f30e 100644 --- a/apps/web/app/sitemap.ts +++ b/apps/web/app/sitemap.ts @@ -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 { if (webConfig.singleUser?.enabled) { return []; } - return getPublicSitemap(); + return [...getPublicSitemap(), ...(await getBlogPostSitemap())]; } diff --git a/apps/web/components/blog-pages.test.tsx b/apps/web/components/blog-pages.test.tsx new file mode 100644 index 0000000..6ff0c96 --- /dev/null +++ b/apps/web/components/blog-pages.test.tsx @@ -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(); + + 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( + +

Rendered MDX body

+
+ ); + + 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' + }; +} diff --git a/apps/web/components/blog-pages.tsx b/apps/web/components/blog-pages.tsx new file mode 100644 index 0000000..8f66d12 --- /dev/null +++ b/apps/web/components/blog-pages.tsx @@ -0,0 +1,246 @@ +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle +} from '@xpenser/ui'; +import { + ArrowLeftIcon, + ArrowRightIcon, + CalendarDaysIcon, + HomeIcon, + TagIcon +} from 'lucide-react'; +import Image from 'next/image'; +import Link from 'next/link'; +import type { ReactNode } from 'react'; +import { + type BlogPost, + blogPostPath, + formatBlogDate, + getBlogPostImage, + getBlogPostKeywords +} from '@/lib/blog'; +import { blogIndexPage } from '@/lib/public-site'; +import { CtaPanel, PublicPageShell } from './landing-page'; + +export function BlogIndexPage({ + posts +}: { + readonly posts: readonly BlogPost[]; +}) { + return ( + +
+
+ + Product updates + +
+

+ {blogIndexPage.h1} +

+

+ {blogIndexPage.description} +

+
+
+
+ +
+ {posts.length === 0 ? ( +
+

+ No published posts yet +

+

+ Published xpenser release notes and feature posts + will appear here after they merge. +

+
+ ) : ( +
+ {posts.map(post => ( + + ))} +
+ )} +
+ + +
+ ); +} + +export function BlogPostPage({ + children, + post +}: { + readonly children: ReactNode; + readonly post: BlogPost; +}) { + return ( + +
+
+
+ + + {post.targetKeyword} + +

+ {post.title} +

+

+ {post.description} +

+ + {post.heroImage ? ( + + ) : null} +
+
+ +
+
+ {children} +
+
+ +
+
+
+ +
+ ); +} + +function BlogPostCard({ post }: { readonly post: BlogPost }) { + return ( + + {post.heroImage ? ( + + ) : null} + +
+ + {formatBlogDate(post.publishedAt)} +
+ + + {post.title} + + +
+ +

+ {post.description} +

+
+ {getBlogPostKeywords(post) + .slice(0, 3) + .map(keyword => ( + + + {keyword} + + ))} +
+ +
+
+ ); +} + +function BlogHeroImage({ + post, + variant +}: { + readonly post: BlogPost; + readonly variant: 'card' | 'post'; +}) { + const image = getBlogPostImage(post); + + return ( +
+ {image.alt} +
+ ); +} + +function BlogBreadcrumb({ current }: { readonly current: string }) { + return ( + + ); +} + +function BlogPostMeta({ post }: { readonly post: BlogPost }) { + return ( +
+ + + Published {formatBlogDate(post.publishedAt)} + + {post.updatedAt && post.updatedAt !== post.publishedAt ? ( + Updated {formatBlogDate(post.updatedAt)} + ) : null} +
+ ); +} diff --git a/apps/web/components/landing-page.tsx b/apps/web/components/landing-page.tsx index 12cbf18..22f46fc 100644 --- a/apps/web/components/landing-page.tsx +++ b/apps/web/components/landing-page.tsx @@ -39,11 +39,13 @@ import { apiDocsPage, apiSettingsScreenshot, appScreenshot, + blogIndexPage, getPublicMarketingPage, mcpEndpointPath, openApiSpecPath, publicMarketingPages, publicSeoPages, + publicUtilityPages, transactionsScreenshot } from '@/lib/public-site'; @@ -329,9 +331,11 @@ function ProofGrid({ items }: { readonly items: readonly string[] }) { } function InternalSeoLinks() { + const pages = [...publicSeoPages, blogIndexPage] as const; + return ( -
- {publicSeoPages.map(page => ( +
+ {pages.map(page => ( @@ -398,11 +402,16 @@ export function PublicSiteHeader() { {navLabel} ))} - + {publicUtilityPages.map(({ navLabel, path }) => ( + + ))}
diff --git a/apps/web/components/mdx-components.tsx b/apps/web/components/mdx-components.tsx new file mode 100644 index 0000000..be5c43f --- /dev/null +++ b/apps/web/components/mdx-components.tsx @@ -0,0 +1,55 @@ +import Link from 'next/link'; +import type { ComponentProps } from 'react'; + +export const mdxComponents = { + a: MdxLink, + code: (props: ComponentProps<'code'>) => ( + + ), + h2: (props: ComponentProps<'h2'>) => ( +

+ ), + h3: (props: ComponentProps<'h3'>) => ( +

+ ), + li: (props: ComponentProps<'li'>) => ( +
  • + ), + ol: (props: ComponentProps<'ol'>) => ( +
      + ), + p: (props: ComponentProps<'p'>) => ( +

      + ), + pre: (props: ComponentProps<'pre'>) => ( +

      +    ),
      +    ul: (props: ComponentProps<'ul'>) => (
      +