Skip to content
Open
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
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ The floating navbar is a mega-menu with multi-column dropdowns. The Technology a
| **Icons** | React Icons | `post-body.tsx`, `more-stories.tsx`, `NotFoundPage.tsx`, `tagIcons.ts` |
| **Date Formatting** | date-fns | `date.tsx` |
| **SEO** | JSON-LD Structured Data | `lib/structured-data.ts` (Organization, WebSite, BlogPosting, TechArticle, BreadcrumbList schemas) |
| **SEO** | Sitemap + robots.txt | `public/sitemap.xml`, `public/robots.txt` |
| **SEO** | Sitemap (robots.txt lives at keploy.io root, keploy/landing) | `public/sitemap.xml` |
| **Analytics** | Google Analytics, Microsoft Clarity, Apollo | `layout.tsx` (tracking scripts) |
| **Deployment** | Vercel + CloudFront | Production at `keploy.io/blog` |
| **CI/CD** | GitHub Actions | `build.yml`, `lighthouse_runner.yml`, `playwright.yml`, `indexnow.yml`, `submit-google-sitemap.yml`, `sync-sitemap.yml` |
Expand Down Expand Up @@ -254,7 +254,6 @@ blog-website/
│ ├── images/ # Static images and illustrations
│ ├── avatars/ # Testimonial avatars
│ ├── favicon/ # Favicon set
│ ├── robots.txt # Crawl rules
│ └── sitemap.xml # Generated sitemap
├── styles/
│ └── index.css # Global styles + Tailwind directives
Expand Down
13 changes: 12 additions & 1 deletion components/PostHeaderAuthors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,14 @@ const PostHeaderAuthors = ({
blogreviewer,
timetoRead,
date,
modified = "",
tags,
}: {
blogwriter: { name: string; ImageUrl: string; description: string }[];
blogreviewer: { name: string; ImageUrl: string; description: string }[];
timetoRead: number;
date: string;
modified?: string;
tags?: { edges: { node: { name: string } }[] };
}) => {
const sameAuthor =
Expand All @@ -209,6 +211,10 @@ const PostHeaderAuthors = ({
writerDismissRef.current = writer.dismiss;

const tagList = tags?.edges?.slice(0, 4) || [];
// Visible "Updated" date only when WP's modified date is a later calendar day
// than the publish date — matches the JSON-LD dateModified.
const showUpdated =
!!modified && !!date && modified.slice(0, 10) > date.slice(0, 10);

return (
<>
Expand Down Expand Up @@ -322,8 +328,13 @@ const PostHeaderAuthors = ({
<div className="flex items-center gap-4 mt-0.5 flex-wrap">
<span className="flex items-center gap-1.5 text-xs sm:text-sm text-gray-500">
<CalendarIcon />
<span>{formatPostDate(date)}</span>
<time dateTime={date}>{formatPostDate(date)}</time>
</span>
{showUpdated && (
<span className="flex items-center gap-1.5 text-xs sm:text-sm text-gray-500">
Updated <time dateTime={modified}>{formatPostDate(modified)}</time>
</span>
)}
<span className="flex items-center gap-1.5 text-xs sm:text-sm text-gray-500">
<ClockIcon />
<span>{timetoRead} minutes</span>
Expand Down
11 changes: 8 additions & 3 deletions components/post-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default function PostHeader({
title,
coverImage,
date,
modified = "",
author,
categories,
BlogWriter,
Expand All @@ -31,13 +32,17 @@ export default function PostHeader({
{/* 3. Title */}
<PostTitle>{title}</PostTitle>

{/* 4. Author card + social share icons */}
<div className="mb-8">
{/* 4. Author card + social share icons. An <aside> (not a <div>) so
readers/LLM extractors treat byline, date, tags and the
Summarize-with-AI CTAs as page chrome, and the article's first
paragraph is the first real content passage. Same box, same layout. */}
<aside aria-label="Post details" className="mb-8">
<PostHeaderAuthors
blogwriter={BlogWriter}
blogreviewer={BlogReviewer}
timetoRead={TimeToRead}
date={date}
modified={modified}
tags={tags}
/>

Expand All @@ -47,7 +52,7 @@ export default function PostHeader({
<SummarizeWithAI />
<PreferredSourceButton />
</div>
</div>
</aside>
</div>
);
}
9 changes: 3 additions & 6 deletions components/post-title.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { toHeadlineCase } from "../utils/titleCase";

export default function PostTitle({ children }: { children: string }) {
function toTitleCase(str: string) {
return str
.toLowerCase()
.replace(/\b\w/g, (word) => word.toUpperCase());
}
const finalString = toTitleCase(children);
const finalString = toHeadlineCase(children);
return (
<h1
style={{
Expand Down
1 change: 0 additions & 1 deletion lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,6 @@ export async function getReviewAuthorDetails(authorName) {
edges {
node {
name
email
avatar {
url
}
Expand Down
52 changes: 52 additions & 0 deletions lib/author-box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,55 @@ export function extractAuthorBox(html?: string): AuthorBoxMeta {
bio: extractAuthorBio(html),
};
}

// PublishPress injects its own author box into post content. On keploy.io it
// renders empty (only an `#author-description-placeholder` div) and carries the
// plugin's hardcoded `box-post-id-16` on every post, so crawlers saw an empty,
// identical author block on all pages. The page renders its own per-post
// AuthorCard from ppmaAuthorName/ppmaAuthorImage + the WP user bio, so the
// plugin markup (and its scoped <style> block) is removed from the body.
// Call extractAuthorBio on the RAW content first if you need its bio.
// Match each complete begin/end block on its own (the body may not contain
// another "begin code"), then drop only blocks that are PublishPress boxes, so
// an earlier unrelated code block can never be swallowed.
const CODE_BLOCK_RE = /<!--\s*begin code\s*-->(?:(?!<!--\s*begin code\s*-->)[\s\S])*?<!--\s*end code\s*-->/gi;
const PP_BOX_STYLE_RE = /<style[^>]*>[^<]*pp-multiple-authors-boxes-wrapper[\s\S]*?<\/style>/gi;

export function stripPublishPressAuthorBox(html?: string | null): string {
if (!html) return "";
return html
.replace(CODE_BLOCK_RE, (block) => (block.includes("pp-multiple-authors") ? "" : block))
.replace(PP_BOX_STYLE_RE, "");
}

type WpUserNode = { name?: string; avatar?: { url?: string }; description?: string };

/**
* Pick the WP user whose display name matches the post's PublishPress author
* exactly (case-insensitive). `users(where:{search})` is a fuzzy match, so a
* loose first-hit would attach another person's bio to the post.
*/
export function pickWpUserForAuthor(
users: { edges?: { node?: WpUserNode }[] } | null | undefined,
authorName?: string | null,
): WpUserNode | null {
const target = (authorName || "").trim().toLowerCase();
if (!target) return null;
return (
users?.edges
?.map((e) => e?.node)
.find((n) => (n?.name || "").trim().toLowerCase() === target) ?? null
);
}

export type PublicAuthor = { name: string; avatar: { url: string } | null; description: string };

/** Whitelist public author fields only — never email or other private data. */
export function toPublicAuthor(node: WpUserNode | null | undefined): PublicAuthor | null {
if (!node?.name) return null;
return {
name: node.name,
avatar: node.avatar?.url ? { url: node.avatar.url } : null,
description: node.description || "",
};
}
13 changes: 12 additions & 1 deletion lib/structured-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export const SOCIAL_LINKS = [
"https://www.g2.com/products/keploy/reviews",
"https://www.capterra.in/software/1070466/Keploy",
"https://aws.amazon.com/marketplace/reviews/reviews-list/prodview-xgwmdk4ivjjv4",
// Wikidata item for Keploy — the entity anchor LLMs use to resolve the brand.
"https://www.wikidata.org/wiki/Q137395272",
];

type BreadcrumbItem = {
Expand Down Expand Up @@ -480,12 +482,16 @@ export const getBlogPostingSchema = ({
"@type": "Organization",
"@id": ORG_ID,
name: ORG_NAME,
url: MAIN_SITE_URL,
logo: {
"@type": "ImageObject",
url: ORG_LOGO_URL,
width: 512,
height: 512,
},
// Repeated here (not only on the global Organization node) so a crawler
// that reads just this article still links the publisher to Wikidata.
sameAs: SOCIAL_LINKS,
},
isPartOf: blogReference(),
// The blog article itself is free to read (no paywall/registration) — a
Expand Down Expand Up @@ -580,7 +586,12 @@ export const getBlogPostingSchema = ({
// primary topic. This is the connective tissue that lets an AI engine attach
// "Keploy does X" claims from any article to the one known tool entity.
schema.mentions = [
{ "@type": "SoftwareApplication", "@id": SOFTWARE_ID, name: ORG_NAME },
{
"@type": "SoftwareApplication",
"@id": SOFTWARE_ID,
name: ORG_NAME,
applicationCategory: "DeveloperApplication",
},
];

return schema;
Expand Down
11 changes: 2 additions & 9 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,8 @@ module.exports = {
},
],
},
{
source: '/robots.txt',
headers: [
{
key: 'Cache-Control',
value: 'public, s-maxage=86400, stale-while-revalidate=86400',
},
],
},
// No /blog/robots.txt: crawlers only read robots.txt at the host root
// (RFC 9309), so all rules live in keploy.io/robots.txt (keploy/landing).
{
source: '/(.*)',
headers: [
Expand Down
37 changes: 32 additions & 5 deletions pages/community/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getMoreStoriesForSlugs,
getPostAndMorePosts,
getReviewAuthors,
getReviewAuthorDetailsCached,
} from "../../lib/api";
import ContainerSlug from "../../components/containerSlug";
import { useEffect, useMemo, useRef } from "react";
Expand All @@ -34,7 +35,12 @@ import {
SITE_URL,
} from "../../lib/structured-data";
import { sanitizeTitle, getSafeDescription, buildPageTitle } from "../../utils/seo";
import { extractAuthorBio } from "../../lib/author-box";
import {
extractAuthorBio,
pickWpUserForAuthor,
toPublicAuthor,
stripPublishPressAuthorBox,
} from "../../lib/author-box";
import { getHowToSchema } from "../../lib/howToSchema";
import { detectCodeLanguages, countWords, extractFaqs } from "../../utils/contentSchema";
import { getTooltipsForSlug } from "../../config/keyword-tooltips";
Expand Down Expand Up @@ -63,7 +69,7 @@ const transformPostContent = (content: string, ppmaAuthorName: string) => {
);
};

export default function Post({ post, posts, reviewAuthorDetails, preview }) {
export default function Post({ post, posts, reviewAuthorDetails, writerDetails, preview }) {
const router = useRouter();
const { slug } = router.query;
const morePosts = posts?.edges;
Expand Down Expand Up @@ -94,7 +100,13 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) {
// "n/a", empty) that would otherwise 400 the next/image optimizer and render a
// broken byline avatar; a real URL passes through unchanged (and it falls back
// to the S3 AUTHOR_AVATAR_PLACEHOLDER when missing).
const writerAvatarUrl = resolveAuthorAvatar(post?.ppmaAuthorImage);
// Already reduced server-side to public fields (name, avatar, bio).
const wpWriter = writerDetails;
const ppmaAvatarUrl = resolveAuthorAvatar(post?.ppmaAuthorImage);
const writerAvatarUrl =
ppmaAvatarUrl === AUTHOR_AVATAR_PLACEHOLDER
? resolveAuthorAvatar(wpWriter?.avatar?.url)
: ppmaAvatarUrl;
// For JSON-LD only: a genuine author photo (absolute URL) or nothing — never
// the placeholder or a junk value, either of which would be invalid in schema.
const rawPpmaImage = (post?.ppmaAuthorImage ?? "").trim();
Expand All @@ -104,7 +116,10 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) {
// content so the real bio is in the SSR HTML (no state, no effect).
// realWriterBio is the actual author-box bio (or undefined); it feeds the
// schema's "real bio only" guard so the generic UI fallback never enters JSON-LD.
const realWriterBio = extractAuthorBio(post?.content);
// Bio priority: PublishPress author-box bio in the content, then the
// author's WordPress user bio (Users > Profile > Biographical Info).
const realWriterBio =
extractAuthorBio(post?.content) || wpWriter?.description?.trim() || undefined;
const blogWriterDescription = realWriterBio ?? "An author for Keploy's blog.";

// Back-compat alias: other parts of this component previously used
Expand Down Expand Up @@ -279,6 +294,7 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) {
title={post?.title || "Loading..."}
coverImage={post?.featuredImage}
date={post?.date || ""}
modified={post?.modified || ""}
author={post?.ppmaAuthorName || ""}
categories={post?.categories || []}
BlogWriter={blogwriter}
Expand All @@ -298,7 +314,7 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) {
<div ref={postBodyRef}>
<PostBody
content={transformPostContent(
post?.content,
stripPublishPressAuthorBox(post?.content),
post?.ppmaAuthorName,
)}
authorName={post?.ppmaAuthorName || ""}
Expand Down Expand Up @@ -396,13 +412,24 @@ export const getStaticProps: GetStaticProps = async ({
// Same two records for every post — memoized in lib/api so a build makes
// this request twice in total instead of twice per post.
const authorDetails = await getReviewAuthors();
// Per-post writer profile (bio/avatar) for the author card. Memoized by
// name; a failure here must never 404 the post, so it degrades to null.
// Select the exact-name WP user here and keep only public fields, so
// nothing private (e.g. email) is serialized into __NEXT_DATA__.
const writerUsers = data.post?.ppmaAuthorName
? await getReviewAuthorDetailsCached(data.post.ppmaAuthorName).catch(() => null)
: null;
const writerDetails = toPublicAuthor(
pickWpUserForAuthor(writerUsers, data.post?.ppmaAuthorName),
);

return {
props: {
preview,
post: data.post,
posts: moreStories?.communityMoreStories || { edges: [] },
reviewAuthorDetails: authorDetails,
writerDetails: writerDetails ?? null,
Comment thread
Copilot marked this conversation as resolved.
},
revalidate: REVALIDATE_CONTENT,
};
Expand Down
Loading
Loading