Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ecd8aac
feat: implement dynamic sitemap with page router and updated wrt buil…
amaan-bhati Mar 31, 2026
204b2a9
feat: migration implementation of app sitemap.ts in page router
amaan-bhati Apr 1, 2026
04fa5f2
feat: fix edge cases and error wp 502 error handling
amaan-bhati Apr 1, 2026
3fde605
Merge branch 'main' into dynamic-sitemap-update
amaan-bhati Apr 1, 2026
d80fc23
fix: reorder import statements in _document.tsx
amaan-bhati Apr 1, 2026
424db4c
Merge branch 'dynamic-sitemap-update' of https://github.com/keploy/bl…
amaan-bhati Apr 1, 2026
97af5d5
chore: test commit to check build pipeline
amaan-bhati Apr 1, 2026
aff778d
feat: add vercel cronjob, refreshsitemap, edge cases, auth and snapshots
amaan-bhati Apr 1, 2026
4c2eeb1
feat: add onew more cron job for auto google indexing using api
amaan-bhati Apr 1, 2026
7751fa3
Merge branch 'main' into dynamic-sitemap-update
nehagup Apr 4, 2026
aeccd79
feat: add comments and fix copilot reviews
amaan-bhati Apr 7, 2026
9bcfa45
chore: fix indentation in refresh-sitemap.ts
amaan-bhati Apr 7, 2026
6e573f0
chore: sync robots.txt and sitemap.xml from main
amaan-bhati Apr 7, 2026
6bbbec0
Merge remote-tracking branch 'origin/main' into dynamic-sitemap-update
amaan-bhati Apr 7, 2026
adf7184
chore: remove sitemap.xml to avoid conflicts with dynamically updated…
amaan-bhati Apr 7, 2026
aee1961
feat: fix and address copilot review#4
amaan-bhati Apr 8, 2026
17e675c
feat: address copilot review#4
amaan-bhati Apr 8, 2026
c69ebc8
feat: fix and address copilot reviews#5
amaan-bhati Apr 8, 2026
69905c2
feat: fix and address copilot review#6
amaan-bhati Apr 8, 2026
4ad9229
Merge branch 'main' into dynamic-sitemap-update
amaan-bhati Apr 8, 2026
6064ea0
feat: address copilot comment#7
amaan-bhati Apr 8, 2026
ff323a0
Merge branch 'dynamic-sitemap-update' of https://github.com/keploy/bl…
amaan-bhati Apr 8, 2026
99fd6c0
Merge remote-tracking branch 'origin/main' into dynamic-sitemap-update
amaan-bhati Apr 8, 2026
f1b2645
feat: fix copilot review#8, fix bugs caught by copilot and claude code
amaan-bhati Apr 8, 2026
ea81570
feat: fix copilot review#9, fix the /tmp folder edge case
amaan-bhati Apr 8, 2026
1649fd6
feat: fix copilot review#10, fix typescript error, empty tagdlug guard
amaan-bhati Apr 8, 2026
0bd1bfd
feat: fix copilot review#10, vercel cache control
amaan-bhati Apr 8, 2026
13390be
feat: fix copilot review#11, fallback sitemapxml inclusion
amaan-bhati Apr 8, 2026
14f78b4
feat: fix copilot review#12, cronsecret missconfigureation and cache …
amaan-bhati Apr 8, 2026
9c28bf2
feat: fix copilot review#13, improve 503 path, apply csp to route pat…
amaan-bhati Apr 9, 2026
e25b848
feat: fix copilot review#14, exclude /api for for grouped negative lo…
amaan-bhati Apr 9, 2026
a558228
chore: copilot minor suggestion address for future people working
amaan-bhati Apr 9, 2026
3266d6b
chore: fix copilot review#16, cusor pagination fix
amaan-bhati Apr 9, 2026
93feab7
Merge branch 'main' into dynamic-sitemap-update
nehagup Apr 9, 2026
fca7d50
feat: migrate to isr generation of sitemap
amaan-bhati Apr 10, 2026
e2ef917
feat: fix the 502 + fix strictnull checks
amaan-bhati Apr 10, 2026
689aa79
feat: create api server ts, server inly using node:https
amaan-bhati Apr 10, 2026
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
17 changes: 17 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Required by Next.js App Router. Every app/ directory MUST have a root layout
// and it MUST include <html> and <body> — Next.js 14 will not inject them
// automatically, and the App Router runtime crashes on root path without them
// (causing a blank screen on localhost:3000/).
//
// All real layout (fonts, global CSS, analytics, structured data) continues to
// live in pages/_app.tsx and pages/_document.tsx, which serve all pages/ routes
// unchanged. This layout is only reached for routes inside app/ — currently just
// app/sitemap.xml/route.ts, which is a Route Handler (not a rendered page), so
// this layout's <body> is never actually visible to users or crawlers.
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
23 changes: 23 additions & 0 deletions app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";

// Required when app/ directory is present alongside pages/.
// Without this file, any unmatched App Router path (e.g. localhost:3000/ outside
// the basePath) shows a blank "__next_error__" screen instead of a proper page.
//
// redirect() from next/navigation cannot be used here because not-found boundaries
// run after the error has already been thrown. A client-side redirect is the
// correct pattern for this boundary.
export default function NotFound() {
useEffect(() => {
// Redirect to the Pages Router 404 page which has the full blog layout.
// basePath /blog is not prepended by window.location — use the full path.
window.location.replace("/blog/404");
}, []);

return (
<div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
<p>Page not found — redirecting...</p>
</div>
);
}
79 changes: 79 additions & 0 deletions app/sitemap.xml/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { getAllPostsForSitemap } from "../../lib/api-server";
import {
adaptPostsForSitemap,
assertFullSitemap,
buildAuthorEntries,
buildPostEntries,
buildTagEntries,
dedupeEntries,
getLatestModified,
getStaticFallbackXml,
serializeSitemap,
STATIC_ROUTES,
} from "../../lib/sitemap";

// ISR: Vercel caches this response in its CDN edge cache for 1 hour.
//
// After first generation: every request is served from CDN (<10ms, no Lambda invoked).
// After TTL expires: stale version served immediately, regeneration happens in background.
// If WordPress is down during regen: Vercel keeps serving the previous good version automatically.
// Cold-start / first request: mitigated by post-deploy warming via .github/workflows/prewarm-sitemap.yml.
export const revalidate = 3600;

export async function GET(): Promise<Response> {
try {
// getAllPostsForSitemap() uses node:https directly (see lib/api-server.ts) to
// bypass Next.js App Router's RSC fetch instrumentation, which causes Cloudflare
// to return 502 HTML when global fetch() is used in this context.
const allPostsResult = await getAllPostsForSitemap();
const posts = adaptPostsForSitemap(allPostsResult);

// Reject partial WordPress responses — ISR will not cache a thrown error,
// so Vercel keeps serving the previous good cached version automatically.
assertFullSitemap(posts);

// Static routes get lastmod = newest post modification time,
// so listing pages reflect when the freshest underlying content changed.
const latestModified = getLatestModified(posts) ?? new Date().toISOString();
const staticEntries = STATIC_ROUTES.map((r) => ({
...r,
lastModified: latestModified,
}));

const entries = dedupeEntries([
...staticEntries,
...buildPostEntries(posts),
...buildAuthorEntries(posts),
...buildTagEntries(posts),
]);

const xml = serializeSitemap(entries);

return new Response(xml, {
status: 200,
headers: {
"Content-Type": "application/xml",
// s-maxage: CDN caches for 1h. stale-while-revalidate: serve stale while
// regenerating in background. max-age=0: browsers always revalidate with CDN.
"Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=3600",
},
});
} catch (error) {
console.error(
"Sitemap generation failed — serving static-routes-only fallback. " +
"Verify WORDPRESS_API_URL is reachable and WPGraphQL is responding.",
error
);

// ISR does NOT cache non-2xx responses.
// no-store prevents any downstream proxy from caching this degraded response,
// so crawlers will retry on the next request once WordPress is back.
return new Response(getStaticFallbackXml(), {
status: 503,
headers: {
"Content-Type": "application/xml",
"Cache-Control": "no-store",
},
});
}
}
4 changes: 2 additions & 2 deletions components/AuthorMapping.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ export default function AuthorMapping({
}) {
const [currentPage, setCurrentPage] = useState(1);

const authorData = [];
const ppmaAuthorNameArray = [];
const authorData: Array<{ publishingAuthor: string; ppmaAuthorName: string; avatarUrl: string; slug: string }> = [];
const ppmaAuthorNameArray: string[] = [];

AuthorArray.forEach((item) => {
const ppmaAuthorName = formatAuthorName(item.ppmaAuthorName);
Expand Down
12 changes: 6 additions & 6 deletions components/NotFoundPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,13 +219,13 @@ const NotFoundPage = ({ latestPosts, communityPosts, technologyPosts }: NotFound
</section>
) : (
<>
{latestPosts?.edges?.length > 0 && (
{(latestPosts?.edges?.length ?? 0) > 0 && (
<section className="py-4">
<h2 className="bg-gradient-to-r from-orange-200 to-orange-100 bg-[length:100%_20px] bg-no-repeat bg-left-bottom w-max mb-8 text-4xl heading1 md:text-4xl font-bold tracking-tighter leading-tight">
Latest from Our Blog
</h2>
<PostGrid>
{latestPosts.edges.slice(0, 6).map(({ node: post }) => (
{(latestPosts?.edges ?? []).slice(0, 6).map(({ node: post }) => (
<PostCard
key={post.slug}
title={post.title}
Expand All @@ -241,13 +241,13 @@ const NotFoundPage = ({ latestPosts, communityPosts, technologyPosts }: NotFound
</section>
)}

{communityPosts?.edges?.length > 0 && (
{(communityPosts?.edges?.length ?? 0) > 0 && (
<section className="py-4">
<h2 className="bg-gradient-to-r from-orange-200 to-orange-100 bg-[length:100%_20px] bg-no-repeat bg-left-bottom w-max mb-8 text-4xl heading1 md:text-4xl font-bold tracking-tighter leading-tight">
Latest Community Blogs
</h2>
<PostGrid>
{communityPosts.edges.slice(0, 6).map(({ node: post }) => (
{(communityPosts?.edges ?? []).slice(0, 6).map(({ node: post }) => (
<PostCard
key={post.slug}
title={post.title}
Expand All @@ -263,13 +263,13 @@ const NotFoundPage = ({ latestPosts, communityPosts, technologyPosts }: NotFound
</section>
)}

{technologyPosts?.edges?.length > 0 && (
{(technologyPosts?.edges?.length ?? 0) > 0 && (
<section className="py-4">
<h2 className="bg-gradient-to-r from-orange-200 to-orange-100 bg-[length:100%_20px] bg-no-repeat bg-left-bottom w-max mb-8 text-4xl heading1 md:text-4xl font-bold tracking-tighter leading-tight">
Latest Technology Blogs
</h2>
<PostGrid>
{technologyPosts.edges.slice(0, 6).map(({ node: post }) => (
{(technologyPosts?.edges ?? []).slice(0, 6).map(({ node: post }) => (
<PostCard
key={post.slug}
title={post.title}
Expand Down
4 changes: 2 additions & 2 deletions components/TableContents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { sanitizeStringForURL } from "../utils/sanitizeStringForUrl";
function TocTooltip({ text, children }: { text: string; children: React.ReactNode }) {
const [show, setShow] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const timeout = useRef<ReturnType<typeof setTimeout>>(null);
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);

const handleEnter = () => {
Expand Down Expand Up @@ -106,7 +106,7 @@ export default function TOC({ headings, isList, setIsList }) {
const element = document.getElementById(sanitizedId);
if (element) {
window.scrollTo({ top: element.offsetTop - 80, behavior: "smooth" });
window.history.replaceState(null, null, `#${sanitizedId}`);
window.history.replaceState(null, "", `#${sanitizedId}`);
}
};

Expand Down
2 changes: 1 addition & 1 deletion components/more-stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default function MoreStories({
const [visibleCount, setVisibleCount] = useState(12);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(initialPageInfo?.hasNextPage ?? true);
const [error, setError] = useState(null);
const [error, setError] = useState<string | null>(null);
const [endCursor, setEndCursor] = useState(initialPageInfo?.endCursor ?? null);
const [buffer, setBuffer] = useState<{ node: Post }[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
Expand Down
6 changes: 3 additions & 3 deletions components/post-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ export default function PostBody({
slug: string | string[] | undefined;
categories?: Post["categories"];
}) {
const [tocItems, setTocItems] = useState([]);
const [copySuccessList, setCopySuccessList] = useState([]);
const [headingCopySuccessList, setHeadingCopySuccessList] = useState([]);
const [tocItems, setTocItems] = useState<{ id: string; title: string | null; type: string }[]>([]);
const [copySuccessList, setCopySuccessList] = useState<boolean[]>([]);
const [headingCopySuccessList, setHeadingCopySuccessList] = useState<boolean[]>([]);
const [isSmallScreen, setIsSmallScreen] = useState(false);
const [replacedContent, setReplacedContent] = useState(content || "");
const [isList, setIsList] = useState(false);
Expand Down
125 changes: 125 additions & 0 deletions lib/api-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Server-only API helpers.
*
* This module uses node:https directly instead of the global fetch() because
* Next.js App Router's RSC context wraps fetch() with its own instrumentation
* layer. That instrumented fetch sends headers that Cloudflare (in front of
* wp.keploy.io) rejects with 502 HTML instead of JSON.
*
* node:https is a raw TCP connection — no Next.js middleware in the path —
* identical to what curl or plain `node -e "fetch()"` sends.
*
* DO NOT import this file from client components. It uses node:https which
* does not exist in the browser. All client-side API calls go through
* lib/api.ts which uses the standard fetch() API.
*/
import https from "node:https";

function getApiUrl(): string {
const url = process.env.WORDPRESS_API_URL || process.env.NEXT_PUBLIC_WORDPRESS_API_URL;
if (!url) {
throw new Error(
"WordPress API URL is not configured. Set WORDPRESS_API_URL or NEXT_PUBLIC_WORDPRESS_API_URL."
);
}
return url;
}

function fetchGraphQL(query: string, variables: Record<string, unknown> = {}): Promise<any> {
const apiUrl = getApiUrl();
const url = new URL(apiUrl);
const body = JSON.stringify({ query, variables });

return new Promise((resolve, reject) => {
const req = https.request(
{
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
"User-Agent": "keploy-blog-sitemap/1.0",
},
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try {
const json = JSON.parse(data);
if (json.errors) reject(new Error(JSON.stringify(json.errors)));
else resolve(json.data);
} catch {
reject(
new Error(
`WordPress returned non-JSON (HTTP ${res.statusCode}): ${data.slice(0, 120)}`
)
);
}
});
}
);
req.on("error", reject);
req.write(body);
req.end();
});
}

/**
* Fetches all posts with only the fields needed for sitemap generation.
* Uses the same cursor-based pagination as getAllPosts() in lib/api.ts but
* requests a smaller field set (no title/excerpt/featuredImage) to reduce
* payload size across the ~10 pagination pages.
*/
export async function getAllPostsForSitemap(): Promise<{ edges: any[] }> {
let allEdges: any[] = [];
let hasNextPage = true;
let endCursor: string | null = null;

while (hasNextPage) {
const data = await fetchGraphQL(
`
query AllPostsForSitemap($after: String) {
posts(first: 50, after: $after, where: { orderby: { field: DATE, order: DESC } }) {
edges {
node {
slug
modified
ppmaAuthorName
categories {
edges {
node {
name
slug
}
}
}
tags {
edges {
node {
name
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`,
{ after: endCursor }
);

const edges = data?.posts?.edges ?? [];
allEdges = [...allEdges, ...edges];
hasNextPage = data?.posts?.pageInfo?.hasNextPage ?? false;
endCursor = data?.posts?.pageInfo?.endCursor ?? null;
}

return { edges: allEdges };
}
Loading
Loading