From be87ca1ca085b227e2b5ea2f3735f3a2231975b0 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:27:19 +0530 Subject: [PATCH 01/66] feat(schema): give Organization a stable @id and link publisher nodes to it Signed-off-by: dhananjay6561 --- lib/structured-data.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/structured-data.ts b/lib/structured-data.ts index 5d69f664..878ef460 100644 --- a/lib/structured-data.ts +++ b/lib/structured-data.ts @@ -5,6 +5,11 @@ export const MAIN_SITE_URL = "https://keploy.io"; export const ORG_NAME = "Keploy"; export const BLOG_NAME = "Keploy Blog"; export const ORG_LOGO_URL = `${SITE_URL}/favicon/android-chrome-512x512.png`; +// Stable @id for the Keploy Organization entity. Every place that emits an +// Organization node (global node, per-post publisher, author worksFor) points +// at this same @id so AI/search engines resolve ONE Keploy entity instead of +// treating each inline copy as a separate organization (entity fragmentation). +export const ORG_ID = `${MAIN_SITE_URL}/#organization`; export const SOCIAL_LINKS = [ "https://twitter.com/Keployio", "https://www.linkedin.com/company/keploy/", @@ -81,6 +86,7 @@ type BlogPostingInput = { export const getOrganizationSchema = () => ({ "@context": "https://schema.org", "@type": "Organization", + "@id": ORG_ID, name: ORG_NAME, url: MAIN_SITE_URL, logo: ORG_LOGO_URL, @@ -190,6 +196,7 @@ export const getBlogPostingSchema = ({ author: authorNode, publisher: { "@type": "Organization", + "@id": ORG_ID, name: ORG_NAME, logo: { "@type": "ImageObject", @@ -263,6 +270,7 @@ export const getBlogSchema = () => ({ "Technical blog covering AI-powered API test generation, eBPF-based testing, production behavior replay, dependency virtualization, and developer productivity by Keploy.", publisher: { "@type": "Organization", + "@id": ORG_ID, name: ORG_NAME, url: MAIN_SITE_URL, logo: { From c605fde0248386d650e77b4bf89bc50055a0ad21 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:28:02 +0530 Subject: [PATCH 02/66] fix(schema): always emit article image as ImageObject with OG fallback Signed-off-by: dhananjay6561 --- lib/structured-data.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/structured-data.ts b/lib/structured-data.ts index 878ef460..504fcfbd 100644 --- a/lib/structured-data.ts +++ b/lib/structured-data.ts @@ -10,6 +10,11 @@ export const ORG_LOGO_URL = `${SITE_URL}/favicon/android-chrome-512x512.png`; // at this same @id so AI/search engines resolve ONE Keploy entity instead of // treating each inline copy as a separate organization (entity fragmentation). export const ORG_ID = `${MAIN_SITE_URL}/#organization`; +// Default article image when a post's WordPress featuredImage is null (common on +// older/migrated posts). Mirrors HOME_OG_IMAGE_URL in lib/constants.ts so the +// schema image matches the og:image the page actually renders. +export const DEFAULT_ARTICLE_IMAGE_URL = + "https://wp.keploy.io/wp-content/uploads/2023/11/thumbnil-.png"; export const SOCIAL_LINKS = [ "https://twitter.com/Keployio", "https://www.linkedin.com/company/keploy/", @@ -243,9 +248,15 @@ export const getBlogPostingSchema = ({ schema.description = description; } - if (imageUrl) { - schema.image = [imageUrl]; - } + // Always emit an image so the Article schema never trips the "missing field + // image" validation error — WordPress returns a null featuredImage on many + // older/migrated posts. Fall back to the site's default OG cover, and emit a + // typed ImageObject (not a bare URL) so rich results can use its caption. + schema.image = { + "@type": "ImageObject", + url: imageUrl || DEFAULT_ARTICLE_IMAGE_URL, + ...(title ? { caption: title } : {}), + }; // TechArticle-specific fields — only emit when set AND when we're // actually rendering a TechArticle. From f394942ed57afe65e5f22473a4339020f84e68ba Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:28:50 +0530 Subject: [PATCH 03/66] fix(schema): guarantee a valid datePublished, never empty or invalid Signed-off-by: dhananjay6561 --- lib/structured-data.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/structured-data.ts b/lib/structured-data.ts index 504fcfbd..616ee44d 100644 --- a/lib/structured-data.ts +++ b/lib/structured-data.ts @@ -150,6 +150,15 @@ export const getBreadcrumbListSchema = (items: BreadcrumbItem[]) => ({ })), }); +// Coerce a possibly-null/malformed WordPress date into a valid ISO 8601 string, +// or null if unparseable. Stops empty / "Invalid Date" values from reaching the +// schema, which is a structured-data validation error. +const toISODate = (value?: string): string | null => { + if (!value) return null; + const d = new Date(value); + return isNaN(d.getTime()) ? null : d.toISOString(); +}; + export const getBlogPostingSchema = ({ title, url, @@ -188,6 +197,12 @@ export const getBlogPostingSchema = ({ authorNode.image = authorImage; } + // Never emit an empty/invalid datePublished. Prefer the post's own date, + // then its modified date, and only as a last resort the current build time. + const resolvedPublished = + toISODate(datePublished) || toISODate(dateModified) || new Date().toISOString(); + const resolvedModified = toISODate(dateModified) || resolvedPublished; + const schema: Record = { "@context": "https://schema.org", "@type": schemaType, @@ -196,8 +211,8 @@ export const getBlogPostingSchema = ({ "@type": "WebPage", "@id": url, }, - datePublished, - dateModified: dateModified || datePublished, + datePublished: resolvedPublished, + dateModified: resolvedModified, author: authorNode, publisher: { "@type": "Organization", From 1644c1d97c500e3d0de82edda2f2347b91873043 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:29:49 +0530 Subject: [PATCH 04/66] fix(schema): sanitize description (strip HTML/entities) before emitting Signed-off-by: dhananjay6561 --- lib/structured-data.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/structured-data.ts b/lib/structured-data.ts index 616ee44d..69f40c03 100644 --- a/lib/structured-data.ts +++ b/lib/structured-data.ts @@ -1,4 +1,5 @@ import { sanitizeAuthorSlug } from "../utils/sanitizeAuthorSlug"; +import { decodeEntities } from "../utils/seo"; export const SITE_URL = "https://keploy.io/blog"; export const MAIN_SITE_URL = "https://keploy.io"; @@ -259,8 +260,14 @@ export const getBlogPostingSchema = ({ schema.articleSection = articleSection; } + // Sanitize the WP excerpt before it enters the schema: strip HTML tags and + // decode entities (script-safe) so unescaped markup/entities can't produce a + // structured-data parse warning. Only emit when something survives. if (description) { - schema.description = description; + const cleanDescription = decodeEntities(description); + if (cleanDescription) { + schema.description = cleanDescription; + } } // Always emit an image so the Article schema never trips the "missing field From dbc577fdcd67f4d2e37665cd080e1dda5f9ae804 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:30:37 +0530 Subject: [PATCH 05/66] fix(schema): fall back to a linked Keploy Team Person when author is missing Signed-off-by: dhananjay6561 --- lib/structured-data.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/structured-data.ts b/lib/structured-data.ts index 69f40c03..ebc680fa 100644 --- a/lib/structured-data.ts +++ b/lib/structured-data.ts @@ -16,6 +16,10 @@ export const ORG_ID = `${MAIN_SITE_URL}/#organization`; // schema image matches the og:image the page actually renders. export const DEFAULT_ARTICLE_IMAGE_URL = "https://wp.keploy.io/wp-content/uploads/2023/11/thumbnil-.png"; +// Author fallback when a post has no usable ppmaAuthorName. A named team +// Person with its own profile URL is a valid author node; the previous +// fallback ("Keploy") produced a URL-less Person, a weaker E-E-A-T signal. +export const AUTHOR_FALLBACK_NAME = "Keploy Team"; export const SOCIAL_LINKS = [ "https://twitter.com/Keployio", "https://www.linkedin.com/company/keploy/", @@ -177,9 +181,8 @@ export const getBlogPostingSchema = ({ reviewerImage, reviewerDescription, }: BlogPostingInput) => { - const resolvedAuthorName = Array.isArray(authorName) - ? (authorName[0] || ORG_NAME) - : (authorName || ORG_NAME); + const resolvedAuthorName = + (Array.isArray(authorName) ? authorName[0] : authorName) || AUTHOR_FALLBACK_NAME; const authorSlug = sanitizeAuthorSlug(resolvedAuthorName); // GEO-13: blog/technology posts render as TechArticle From 29dd4b6cea663f6aff8c047172398e15ff58ccda Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:31:39 +0530 Subject: [PATCH 06/66] test(schema): assert Article stays valid for null/malformed WP inputs Signed-off-by: dhananjay6561 --- tests/lib/structuredData.test.ts | 115 +++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/lib/structuredData.test.ts diff --git a/tests/lib/structuredData.test.ts b/tests/lib/structuredData.test.ts new file mode 100644 index 00000000..f7e24073 --- /dev/null +++ b/tests/lib/structuredData.test.ts @@ -0,0 +1,115 @@ +/** + * Unit tests for getBlogPostingSchema hardening (A1 / AI1). + * + * Run via: `npm run test:unit` + * + * WordPress returns null/malformed values (featuredImage, date, author, + * excerpt) on many older/migrated posts, and SEMrush flagged 362 posts with + * invalid structured data as a result. These cases pin the contract that ANY + * input — including all-null — still produces a schema.org-valid Article, so a + * regression in the builder's fallbacks fails CI immediately rather than + * silently shipping invalid schema. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + getBlogPostingSchema, + getOrganizationSchema, + ORG_ID, + DEFAULT_ARTICLE_IMAGE_URL, + AUTHOR_FALLBACK_NAME, +} from "../../lib/structured-data"; + +const isValidISODate = (v: unknown): boolean => + typeof v === "string" && v.length > 0 && !isNaN(new Date(v).getTime()); + +// The worst case: a migrated post where WP returned nulls for everything. +const nullPost = getBlogPostingSchema({ + title: "A Post With Missing Data", + url: "https://keploy.io/blog/community/some-post", + datePublished: "", + imageUrl: undefined, + authorName: undefined, + description: undefined, +}); + +test("image is always emitted as an ImageObject, even when featuredImage is null", () => { + assert.ok(nullPost.image, "image must be present"); + const image = nullPost.image as Record; + assert.equal(image["@type"], "ImageObject"); + assert.equal(image.url, DEFAULT_ARTICLE_IMAGE_URL); +}); + +test("a real featuredImage is used and carries the title as caption", () => { + const withImage = getBlogPostingSchema({ + title: "Real Image Post", + url: "https://keploy.io/blog/community/x", + datePublished: "2024-01-02", + imageUrl: "https://wp.keploy.io/wp-content/uploads/real.png", + }); + const image = withImage.image as Record; + assert.equal(image.url, "https://wp.keploy.io/wp-content/uploads/real.png"); + assert.equal(image.caption, "Real Image Post"); +}); + +test("datePublished is always a valid ISO date, never empty or invalid", () => { + assert.ok(isValidISODate(nullPost.datePublished), "datePublished must be valid ISO"); + assert.ok(isValidISODate(nullPost.dateModified), "dateModified must be valid ISO"); +}); + +test("an invalid datePublished falls back to a valid dateModified", () => { + const post = getBlogPostingSchema({ + title: "Bad publish date", + url: "https://keploy.io/blog/community/y", + datePublished: "not-a-date", + dateModified: "2023-05-06", + }); + assert.ok(isValidISODate(post.datePublished)); + assert.equal( + new Date(post.datePublished as string).getUTCFullYear(), + 2023, + ); +}); + +test("author always resolves to a linked Keploy Team Person when missing", () => { + const author = nullPost.author as Record; + assert.equal(author["@type"], "Person"); + assert.equal(author.name, AUTHOR_FALLBACK_NAME); + assert.equal(author.url, "https://keploy.io/blog/authors/keploy-team"); +}); + +test("description is stripped of HTML tags before entering the schema", () => { + const post = getBlogPostingSchema({ + title: "HTML excerpt", + url: "https://keploy.io/blog/community/z", + datePublished: "2024-01-02", + description: "

Hello world & more

", + }); + const desc = post.description as string; + assert.ok(!/[<>]/.test(desc), "no raw HTML tags should remain"); + assert.ok(desc.includes("Hello world"), "text content is preserved"); +}); + +test("publisher and the Organization node share one stable @id", () => { + const org = getOrganizationSchema(); + assert.equal(org["@id"], ORG_ID); + const publisher = nullPost.publisher as Record; + assert.equal(publisher["@id"], ORG_ID); +}); + +test("core required Article fields are always present", () => { + for (const field of ["@context", "@type", "headline", "author", "publisher", "image", "datePublished"]) { + assert.ok(nullPost[field] !== undefined, `${field} must be present`); + } +}); + +test("technology posts render as TechArticle", () => { + const tech = getBlogPostingSchema({ + title: "Tech post", + url: "https://keploy.io/blog/technology/x", + datePublished: "2024-01-02", + categorySlug: "technology", + }); + assert.equal(tech["@type"], "TechArticle"); +}); From 46eb1eec8bff95936f052d0588792692e5e4bd41 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:43:51 +0530 Subject: [PATCH 07/66] feat(seo): add on-page h1 to tag, community, and technology archive templates Signed-off-by: dhananjay6561 --- pages/community/index.tsx | 3 +++ pages/tag/[slug].tsx | 3 +++ pages/technology/index.tsx | 3 +++ 3 files changed, 9 insertions(+) diff --git a/pages/community/index.tsx b/pages/community/index.tsx index 14d248a6..f00060fd 100644 --- a/pages/community/index.tsx +++ b/pages/community/index.tsx @@ -46,6 +46,9 @@ export default function Community({ allPosts: { edges, pageInfo }, preview }) {
+

+ Keploy Community Blog +

{/* */} {heroPost && (
+

+ {tagDisplay} posts +

diff --git a/pages/technology/index.tsx b/pages/technology/index.tsx index a8c01fa5..3901efd0 100644 --- a/pages/technology/index.tsx +++ b/pages/technology/index.tsx @@ -36,6 +36,9 @@ export default function Index({ allPosts: { edges, pageInfo }, preview }) {
+

+ Keploy Technology Blog +

{/* */} {heroPost && ( Date: Fri, 7 Aug 2026 15:46:05 +0530 Subject: [PATCH 08/66] feat(seo): cap post at 60 chars via buildPageTitle helper Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com> --- pages/community/[slug].tsx | 4 ++-- pages/technology/[slug].tsx | 4 ++-- utils/seo.ts | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/pages/community/[slug].tsx b/pages/community/[slug].tsx index f93cd645..071478b4 100644 --- a/pages/community/[slug].tsx +++ b/pages/community/[slug].tsx @@ -29,7 +29,7 @@ import { getBreadcrumbListSchema, SITE_URL, } from "../../lib/structured-data"; -import { sanitizeTitle, getSafeDescription } from "../../utils/seo"; +import { sanitizeTitle, getSafeDescription, buildPageTitle } from "../../utils/seo"; import { getHowToSchema } from "../../lib/howToSchema"; const PostBody = dynamic(() => import("../../components/post-body")); @@ -219,7 +219,7 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) { <> <article> <Head> - <title>{`${post?.title || "Loading..."} | Keploy Blog`} + {buildPageTitle(post?.title)} {/* DM Sans + Baloo 2 are preloaded globally in _document.tsx */} import("../../components/post-body")); @@ -197,7 +197,7 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) { <>
- {`${post?.title || "Loading..."} | Keploy Blog`} + {buildPageTitle(post?.title)} {/* DM Sans + Baloo 2 are preloaded globally in _document.tsx */} that stays within the ~60-char SERP limit (SEMrush "title + * too long", 113 posts). Order of preference: + * 1. `Title | Keploy Blog` when it fits, + * 2. the bare title when adding the suffix would overflow but the title fits, + * 3. the title truncated at a word boundary as a last resort. + * Entity-decoded via sanitizeTitle so WP entities don't inflate the length. + */ +export function buildPageTitle(rawTitle: string | undefined | null): string { + const base = sanitizeTitle(rawTitle).trim() || "Keploy Blog"; + const withSuffix = `${base}${TITLE_SUFFIX}`; + if (withSuffix.length <= MAX_TITLE_LENGTH) return withSuffix; + if (base.length <= MAX_TITLE_LENGTH) return base; + const clipped = base.slice(0, MAX_TITLE_LENGTH); + const lastSpace = clipped.lastIndexOf(" "); + return (lastSpace > 40 ? clipped.slice(0, lastSpace) : clipped).trimEnd(); +} + /** * Generate a safe meta description for a blog post. * Uses Yoast metaDesc if available and long enough, otherwise generates from title. From 5651d041d3e0932616a4857aa0d9e7d56c06c4c5 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:47:05 +0530 Subject: [PATCH 09/66] test(seo): pin buildPageTitle 60-char invariant; fix empty-title edge case Signed-off-by: dhananjay6561 --- tests/lib/seo.test.ts | 48 +++++++++++++++++++++++++++++++++++++++++++ utils/seo.ts | 3 ++- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/lib/seo.test.ts diff --git a/tests/lib/seo.test.ts b/tests/lib/seo.test.ts new file mode 100644 index 00000000..bfd3e4f3 --- /dev/null +++ b/tests/lib/seo.test.ts @@ -0,0 +1,48 @@ +/** + * Unit tests for buildPageTitle (A3 — "title too long", 113 posts). + * Run via: `npm run test:unit`. Pins the ≤60-char invariant so a regression + * in the truncation logic fails CI instead of silently shipping long titles. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildPageTitle } from "../../utils/seo"; + +test("short title keeps the ' | Keploy Blog' suffix", () => { + assert.equal(buildPageTitle("Unit Testing 101"), "Unit Testing 101 | Keploy Blog"); +}); + +test("a title that fits without the suffix drops the suffix rather than overflow", () => { + // 52 chars — adding the 14-char suffix would exceed 60, so suffix is dropped. + const title = "A Fairly Long Blog Post Title About API Testing Here"; + const out = buildPageTitle(title); + assert.ok(out.length <= 60); + assert.equal(out, title); +}); + +test("an over-long title is truncated at a word boundary, never mid-word", () => { + const out = buildPageTitle( + "The Complete Definitive Comprehensive Guide To End To End Integration Testing In Modern Microservices", + ); + assert.ok(out.length <= 60, `got ${out.length}`); + assert.ok(!out.endsWith(" ")); + // last token is a whole word (truncation happened at a space) + assert.ok(!/\S$/.test(out) === false); // ends with a non-space char +}); + +test("null/undefined/empty falls back to a valid title", () => { + assert.equal(buildPageTitle(undefined), "Keploy Blog"); + assert.equal(buildPageTitle(null), "Keploy Blog"); + assert.equal(buildPageTitle(""), "Keploy Blog"); +}); + +test("output is always within the 60-char SERP limit", () => { + for (const t of [ + "x", + "A".repeat(200), + "word ".repeat(50), + "Exactly Sixty Characters Would Go Right About Here Or So Yes!!", + ]) { + assert.ok(buildPageTitle(t).length <= 60, `too long for input len ${t.length}`); + } +}); diff --git a/utils/seo.ts b/utils/seo.ts index dab710d5..3960ff0a 100644 --- a/utils/seo.ts +++ b/utils/seo.ts @@ -123,7 +123,8 @@ const MAX_TITLE_LENGTH = 60; * Entity-decoded via sanitizeTitle so WP entities don't inflate the length. */ export function buildPageTitle(rawTitle: string | undefined | null): string { - const base = sanitizeTitle(rawTitle).trim() || "Keploy Blog"; + const base = sanitizeTitle(rawTitle).trim(); + if (!base) return "Keploy Blog"; const withSuffix = `${base}${TITLE_SUFFIX}`; if (withSuffix.length <= MAX_TITLE_LENGTH) return withSuffix; if (base.length <= MAX_TITLE_LENGTH) return base; From 81a25541ec900ac52dd2224597ec6bee204cf210 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:49:06 +0530 Subject: [PATCH 10/66] fix(a11y): give cover-image link a non-empty aria-label and alt fallback Signed-off-by: dhananjay6561 --- components/cover-image.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/components/cover-image.tsx b/components/cover-image.tsx index fe1db300..88ddb162 100644 --- a/components/cover-image.tsx +++ b/components/cover-image.tsx @@ -22,12 +22,15 @@ export default function CoverImage({ sizes = "(max-width: 768px) 100vw, (max-width: 1200px) 780px, 780px", }: Props) { const basePath = isCommunity ? "/community/" : "/technology/"; + // Never let the link's accessible name or the image alt end up empty (some + // cards render without a title) — an empty aria-label is an anchor-less link. + const safeTitle = title || "Keploy blog article"; const image = ( {`Cover {slug ? ( - + {image} ) : ( From aec2ebabc7dcbc5bf26c0647de6bde8336c54318 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:49:39 +0530 Subject: [PATCH 11/66] fix(a11y): fall back to default cover when WP featuredImage URL is missing Signed-off-by: dhananjay6561 --- components/cover-image.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/cover-image.tsx b/components/cover-image.tsx index 88ddb162..6f319e60 100644 --- a/components/cover-image.tsx +++ b/components/cover-image.tsx @@ -1,6 +1,7 @@ import Image from "next/image"; import Link from "next/link"; import { Post } from "../types/post"; +import { DEFAULT_ARTICLE_IMAGE_URL } from "../lib/structured-data"; interface Props extends Partial> { coverImage: Post["featuredImage"]; @@ -31,7 +32,7 @@ export default function CoverImage({ width={2000} height={1000} alt={`Cover Image for ${safeTitle}`} - src={coverImage?.node.sourceUrl} + src={coverImage?.node?.sourceUrl || DEFAULT_ARTICLE_IMAGE_URL} className={`w-full h-auto object-cover${imgClassName ? ` ${imgClassName}` : ""}${slug ? " transition-transform duration-300 hover:scale-[1.01]" : ""}`} priority={priority} loading={priority ? "eager" : "lazy"} From cc741b97887bc24689c2cafa88f2fc5d95483ac5 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:50:29 +0530 Subject: [PATCH 12/66] feat(schema): add reusable getImageObjectSchema and use it for article image Signed-off-by: dhananjay6561 --- lib/structured-data.ts | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/lib/structured-data.ts b/lib/structured-data.ts index ebc680fa..cfb5bf11 100644 --- a/lib/structured-data.ts +++ b/lib/structured-data.ts @@ -144,6 +144,29 @@ export const getWebSiteSchema = (searchTarget = `${SITE_URL}/search?q={search_te }, }); +/** + * Reusable ImageObject node. Google/AI engines prefer a typed ImageObject over + * a bare URL string (it can carry caption + dimensions), and it's the shape the + * Article `image` field and listing/cover images should share. + */ +export const getImageObjectSchema = ({ + url, + caption, + width, + height, +}: { + url: string; + caption?: string; + width?: number; + height?: number; +}) => ({ + "@type": "ImageObject", + url, + ...(caption ? { caption } : {}), + ...(typeof width === "number" ? { width } : {}), + ...(typeof height === "number" ? { height } : {}), +}); + export const getBreadcrumbListSchema = (items: BreadcrumbItem[]) => ({ "@context": "https://schema.org", "@type": "BreadcrumbList", @@ -277,11 +300,10 @@ export const getBlogPostingSchema = ({ // image" validation error — WordPress returns a null featuredImage on many // older/migrated posts. Fall back to the site's default OG cover, and emit a // typed ImageObject (not a bare URL) so rich results can use its caption. - schema.image = { - "@type": "ImageObject", + schema.image = getImageObjectSchema({ url: imageUrl || DEFAULT_ARTICLE_IMAGE_URL, - ...(title ? { caption: title } : {}), - }; + caption: title, + }); // TechArticle-specific fields — only emit when set AND when we're // actually rendering a TechArticle. From dfb459b70e010e637f952fc743bb789aaf7e61ed Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:51:27 +0530 Subject: [PATCH 13/66] feat(schema): add getItemListSchema and getCollectionPageSchema builders Signed-off-by: dhananjay6561 --- lib/structured-data.ts | 54 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/lib/structured-data.ts b/lib/structured-data.ts index cfb5bf11..121fb4c7 100644 --- a/lib/structured-data.ts +++ b/lib/structured-data.ts @@ -187,6 +187,60 @@ const toISODate = (value?: string): string | null => { return isNaN(d.getTime()) ? null : d.toISOString(); }; +type ListEntry = { url: string; name: string }; + +const toListItems = (items: ListEntry[]) => + items.map((it, index) => ({ + "@type": "ListItem", + position: index + 1, + url: it.url, + name: it.name, + })); + +/** + * ItemList for a grid/collection of posts. Emitted standalone (has @context) so + * AI/search engines can map the ordered set of links on listing pages, which + * currently ship zero collection schema. + */ +export const getItemListSchema = (items: ListEntry[], listName?: string) => ({ + "@context": "https://schema.org", + "@type": "ItemList", + ...(listName ? { name: listName } : {}), + itemListElement: toListItems(items), +}); + +/** + * CollectionPage for an archive/listing route (community, technology, tag, + * authors). Wraps the post/author grid as an ItemList mainEntity so the whole + * archive is a modeled collection rather than an unlabeled wall of cards. + */ +export const getCollectionPageSchema = ({ + name, + url, + description, + items, +}: { + name: string; + url: string; + description?: string; + items: ListEntry[]; +}) => ({ + "@context": "https://schema.org", + "@type": "CollectionPage", + name, + url, + ...(description ? { description } : {}), + isPartOf: { + "@type": "Blog", + name: BLOG_NAME, + url: SITE_URL, + }, + mainEntity: { + "@type": "ItemList", + itemListElement: toListItems(items), + }, +}); + export const getBlogPostingSchema = ({ title, url, From 96bc97f555839af8f3d35628f53e39a384765704 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:53:19 +0530 Subject: [PATCH 14/66] feat(schema): emit CollectionPage/ItemList on community, technology, tag, author listings Signed-off-by: dhananjay6561 --- pages/authors/index.tsx | 15 ++++++++++++++- pages/community/index.tsx | 12 +++++++++++- pages/tag/[slug].tsx | 15 ++++++++++++++- pages/technology/index.tsx | 13 +++++++++++-- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/pages/authors/index.tsx b/pages/authors/index.tsx index f5ee65e6..378ef631 100644 --- a/pages/authors/index.tsx +++ b/pages/authors/index.tsx @@ -6,7 +6,8 @@ import Container from "../../components/container"; import AuthorMapping from "../../components/AuthorMapping"; import { HOME_OG_IMAGE_URL } from "../../lib/constants"; import { Post } from "../../types/post"; -import { getBreadcrumbListSchema, SITE_URL } from "../../lib/structured-data"; +import { getBreadcrumbListSchema, getCollectionPageSchema, SITE_URL } from "../../lib/structured-data"; +import { sanitizeAuthorSlug } from "../../utils/sanitizeAuthorSlug"; import { REVALIDATE_CONTENT } from "../../lib/isr"; export default function Authors({ @@ -34,6 +35,18 @@ export default function Authors({ { name: "Home", url: SITE_URL }, { name: "Authors", url: `${SITE_URL}/authors` }, ]), + getCollectionPageSchema({ + name: "Keploy Blog Authors", + url: `${SITE_URL}/authors`, + description: + "Engineers, developers, and QA experts writing on the Keploy blog about testing, automation, and software quality.", + items: authorArray + .filter((node) => node.ppmaAuthorName) + .map((node) => ({ + url: `${SITE_URL}/authors/${sanitizeAuthorSlug(node.ppmaAuthorName)}`, + name: node.ppmaAuthorName, + })), + }), ]} canonicalUrl={`${SITE_URL}/authors`} > diff --git a/pages/community/index.tsx b/pages/community/index.tsx index f00060fd..eab3042a 100644 --- a/pages/community/index.tsx +++ b/pages/community/index.tsx @@ -6,7 +6,7 @@ import HeroPost from "../../components/hero-post"; import Layout from "../../components/layout"; import { getAllPostsForCommunity } from "../../lib/api"; import Header from "../../components/header"; -import { getBreadcrumbListSchema, SITE_URL } from "../../lib/structured-data"; +import { getBreadcrumbListSchema, getCollectionPageSchema, SITE_URL } from "../../lib/structured-data"; import { REVALIDATE_CONTENT } from "../../lib/isr"; export default function Community({ allPosts: { edges, pageInfo }, preview }) { @@ -18,6 +18,16 @@ export default function Community({ allPosts: { edges, pageInfo }, preview }) { { name: "Home", url: SITE_URL }, { name: "Community", url: `${SITE_URL}/community` }, ]), + getCollectionPageSchema({ + name: "Keploy Community Blog", + url: `${SITE_URL}/community`, + description: + "Developer stories, open-source contributions, API testing tutorials, and hands-on engineering guides from the Keploy community.", + items: edges.map(({ node }) => ({ + url: `${SITE_URL}/community/${node.slug}`, + name: node.title, + })), + }), ]; function getExcerpt(content) { const maxWords = 50; diff --git a/pages/tag/[slug].tsx b/pages/tag/[slug].tsx index 249d7580..a3ab6773 100644 --- a/pages/tag/[slug].tsx +++ b/pages/tag/[slug].tsx @@ -7,7 +7,7 @@ import Container from "../../components/container"; import { getAllPostsFromTags, getAllTags } from "../../lib/api"; import TagsStories from "../../components/TagsStories"; import { useRouter } from "next/router"; -import { getBreadcrumbListSchema, SITE_URL } from "../../lib/structured-data"; +import { getBreadcrumbListSchema, getCollectionPageSchema, SITE_URL } from "../../lib/structured-data"; import { REVALIDATE_CONTENT, REVALIDATE_ERROR, REVALIDATE_NOT_FOUND } from "../../lib/isr"; export default function PostByTags({ postsByTags, preview, tagSlug: tagSlugProp }) { const posts = postsByTags?.edges || []; @@ -26,6 +26,19 @@ export default function PostByTags({ postsByTags, preview, tagSlug: tagSlugProp { name: "Tags", url: `${SITE_URL}/tag` }, { name: `${tagDisplay || "Tag"}`, url: `${SITE_URL}/tag/${tagSlug || ""}` }, ]), + getCollectionPageSchema({ + name: `${tagDisplay} posts`, + url: `${SITE_URL}/tag/${tagSlug || ""}`, + description: `Keploy blog posts tagged "${tagDisplay}".`, + items: posts.map(({ node }: any) => { + const isCommunity = + node.categories?.edges?.[0]?.node?.name === "community"; + return { + url: `${SITE_URL}/${isCommunity ? "community" : "technology"}/${node.slug}`, + name: node.title, + }; + }), + }), ]} canonicalUrl={tagSlug ? `${SITE_URL}/tag/${tagSlug}` : `${SITE_URL}/tag`} > diff --git a/pages/technology/index.tsx b/pages/technology/index.tsx index 3901efd0..a1564ec1 100644 --- a/pages/technology/index.tsx +++ b/pages/technology/index.tsx @@ -7,11 +7,10 @@ import Layout from "../../components/layout"; import { getAllPostsForTechnology } from "../../lib/api"; import Header from "../../components/header"; import { getExcerpt } from "../../utils/excerpt"; -import { getBreadcrumbListSchema, SITE_URL } from "../../lib/structured-data"; +import { getBreadcrumbListSchema, getCollectionPageSchema, SITE_URL } from "../../lib/structured-data"; import { REVALIDATE_CONTENT, REVALIDATE_ERROR } from "../../lib/isr"; export default function Index({ allPosts: { edges, pageInfo }, preview }) { - console.log("tech posts: ", edges.length) const heroPost = edges[0]?.node; const excerpt = edges[0] ? getExcerpt(edges[0].node.excerpt, 50) : null; const morePosts = edges.slice(1); @@ -20,6 +19,16 @@ export default function Index({ allPosts: { edges, pageInfo }, preview }) { { name: "Home", url: SITE_URL }, { name: "Technology", url: `${SITE_URL}/technology` }, ]), + getCollectionPageSchema({ + name: "Keploy Technology Blog", + url: `${SITE_URL}/technology`, + description: + "In-depth technology articles on API testing, test automation, CI/CD pipelines, eBPF-based testing, and modern software quality engineering.", + items: edges.map(({ node }) => ({ + url: `${SITE_URL}/technology/${node.slug}`, + name: node.title, + })), + }), ]; return ( From 65f94ff2081deb58884a1134bedb0dbe457ae458 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:55:24 +0530 Subject: [PATCH 15/66] feat(schema): wrap author in ProfilePage, enrich Person (image/sameAs/@id), add authored-works ItemList Signed-off-by: dhananjay6561 --- pages/authors/[slug].tsx | 50 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/pages/authors/[slug].tsx b/pages/authors/[slug].tsx index c9de55c2..ac55e3e7 100644 --- a/pages/authors/[slug].tsx +++ b/pages/authors/[slug].tsx @@ -11,7 +11,28 @@ import { GetStaticPaths, GetStaticProps } from "next"; import PostByAuthorMapping from "../../components/postByAuthorMapping"; import { HOME_OG_IMAGE_URL } from "../../lib/constants"; import { sanitizeAuthorSlug } from "../../utils/sanitizeAuthorSlug"; -import { getBreadcrumbListSchema, MAIN_SITE_URL, SITE_URL } from "../../lib/structured-data"; +import { + getBreadcrumbListSchema, + getItemListSchema, + MAIN_SITE_URL, + SITE_URL, + ORG_ID, +} from "../../lib/structured-data"; + +// Server-safe author-box extraction. extractAuthorData (utils) relies on +// `document`, so it can't run in getStaticProps/SSR — these regexes pull the +// same fields from the raw PublishPress author-box HTML for the JSON-LD. +function extractAuthorMeta(html: string): { avatarUrl?: string; linkedIn?: string } { + if (!html) return {}; + const avatar = html.match( + /pp-author-boxes-avatar[\s\S]{0,200}?]+src=["']([^"']+)["']/i, + ); + const linkedIn = html.match(/href=["'](https?:\/\/[^"']*linkedin\.com[^"']*)["']/i); + return { + avatarUrl: avatar?.[1], + linkedIn: linkedIn?.[1], + }; +} import { REVALIDATE_CONTENT, REVALIDATE_ERROR, REVALIDATE_NOT_FOUND } from "../../lib/isr"; export default function AuthorPage({ preview, filteredPosts, content }) { @@ -34,14 +55,22 @@ export default function AuthorPage({ preview, filteredPosts, content }) { // weight the authority of the pages they cite. worksFor.url points at // MAIN_SITE_URL (https://keploy.io) — not the blog subpath — so the // Organization entity is consistent across every JSON-LD payload. - const personSchema = { - "@context": "https://schema.org", + const authorMeta = extractAuthorMeta(content || ""); + const authoredItems = filteredPosts.map(({ node }) => ({ + url: `${SITE_URL}/${node?.categories?.edges?.[0]?.node?.name === "community" ? "community" : "technology"}/${node.slug}`, + name: node.title, + })); + + // Enriched Person node (no @context — it's nested as ProfilePage.mainEntity). + const personNode: Record = { "@type": "Person", + "@id": `${authorUrl}#person`, name: authorName, url: authorUrl, jobTitle: "Contributor", worksFor: { "@type": "Organization", + "@id": ORG_ID, name: "Keploy", url: MAIN_SITE_URL, }, @@ -52,6 +81,18 @@ export default function AuthorPage({ preview, filteredPosts, content }) { "Developer Tools", ], }; + if (authorMeta.avatarUrl) personNode.image = authorMeta.avatarUrl; + if (authorMeta.linkedIn) personNode.sameAs = [authorMeta.linkedIn]; + + // Wrap the author identity in a ProfilePage (the route IS a profile), and + // model the author's posts as an ItemList so AI engines see the body of work. + const profilePageSchema = { + "@context": "https://schema.org", + "@type": "ProfilePage", + mainEntity: personNode, + mainEntityOfPage: authorUrl, + }; + const authoredWorksSchema = getItemListSchema(authoredItems, `Posts by ${authorName}`); return (
@@ -76,7 +117,8 @@ export default function AuthorPage({ preview, filteredPosts, content }) { { name: "Authors", url: `${SITE_URL}/authors` }, { name: authorName, url: authorUrl }, ]), - personSchema, + profilePageSchema, + authoredWorksSchema, ]} canonicalUrl={authorUrl} > From 6e36858ecf4abcfbb6dedec5c08cb5748a51283d Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:58:16 +0530 Subject: [PATCH 16/66] feat(schema): add FAQPage, DefinedTermSet, SoftwareSourceCode, SearchResultsPage builders + speakable support Signed-off-by: dhananjay6561 --- lib/structured-data.ts | 103 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/lib/structured-data.ts b/lib/structured-data.ts index 121fb4c7..ab515f14 100644 --- a/lib/structured-data.ts +++ b/lib/structured-data.ts @@ -91,6 +91,11 @@ type BlogPostingInput = { * Reviewer description / job title. */ reviewerDescription?: string; + /** + * CSS selectors for the sections a voice assistant should read aloud + * (e.g. the intro + key-takeaways). Emits a SpeakableSpecification. + */ + speakableSelectors?: string[]; }; export const getOrganizationSchema = () => ({ @@ -257,6 +262,7 @@ export const getBlogPostingSchema = ({ reviewerName, reviewerImage, reviewerDescription, + speakableSelectors, }: BlogPostingInput) => { const resolvedAuthorName = (Array.isArray(authorName) ? authorName[0] : authorName) || AUTHOR_FALLBACK_NAME; @@ -350,6 +356,13 @@ export const getBlogPostingSchema = ({ } } + if (speakableSelectors && speakableSelectors.length > 0) { + schema.speakable = { + "@type": "SpeakableSpecification", + cssSelector: speakableSelectors, + }; + } + // Always emit an image so the Article schema never trips the "missing field // image" validation error — WordPress returns a null featuredImage on many // older/migrated posts. Fall back to the site's default OG cover, and emit a @@ -373,6 +386,96 @@ export const getBlogPostingSchema = ({ return schema; }; +/** + * FAQPage from extracted question/answer pairs in a post body. AI answer + * engines cite FAQ Q&A directly, so surfacing them as structured data is high + * leverage. Only call this when real Q&A pairs were detected. + */ +export const getFAQPageSchema = (faqs: { question: string; answer: string }[]) => ({ + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: faqs.map((f) => ({ + "@type": "Question", + name: f.question, + acceptedAnswer: { + "@type": "Answer", + text: f.answer, + }, + })), +}); + +/** + * DefinedTermSet for a post's keyword-tooltip glossary. Each tooltip term is a + * DefinedTerm so AI engines can extract Keploy's canonical definitions. + */ +export const getDefinedTermSetSchema = ({ + name, + url, + terms, +}: { + name: string; + url: string; + terms: { term: string; description?: string }[]; +}) => ({ + "@context": "https://schema.org", + "@type": "DefinedTermSet", + name, + url, + hasDefinedTerm: terms.map((t) => ({ + "@type": "DefinedTerm", + name: t.term, + ...(t.description ? { description: t.description } : {}), + inDefinedTermSet: url, + })), +}); + +/** + * One SoftwareSourceCode node per programming language present in a post's code + * blocks. Signals to AI engines that the article contains runnable code in + * those languages (developer-intent queries weight this). + */ +export const getSoftwareSourceCodeSchema = ({ + language, + url, + name, +}: { + language: string; + url: string; + name?: string; +}) => ({ + "@context": "https://schema.org", + "@type": "SoftwareSourceCode", + programmingLanguage: language, + ...(name ? { name } : {}), + codeRepository: "https://github.com/keploy/keploy", + isPartOf: { + "@type": "WebPage", + "@id": url, + }, +}); + +/** + * SearchResultsPage for the /search and /community/search routes, which render + * a filtered result grid but currently emit no page-type schema. + */ +export const getSearchResultsPageSchema = ({ + url, + query, +}: { + url: string; + query?: string; +}) => ({ + "@context": "https://schema.org", + "@type": "SearchResultsPage", + url, + name: query ? `Search results for "${query}"` : "Search the Keploy blog", + isPartOf: { + "@type": "WebSite", + name: BLOG_NAME, + url: SITE_URL, + }, +}); + export const getBlogSchema = () => ({ "@context": "https://schema.org", "@type": "Blog", From 92546c1884ea7e8a3c01b1bf50e926e8e8be13b1 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 15:59:32 +0530 Subject: [PATCH 17/66] feat(schema): server-safe detectors for code languages and FAQ pairs Signed-off-by: dhananjay6561 --- tests/lib/contentSchema.test.ts | 35 ++++++++++++++ utils/contentSchema.ts | 85 +++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 tests/lib/contentSchema.test.ts create mode 100644 utils/contentSchema.ts diff --git a/tests/lib/contentSchema.test.ts b/tests/lib/contentSchema.test.ts new file mode 100644 index 00000000..dfeb7f0c --- /dev/null +++ b/tests/lib/contentSchema.test.ts @@ -0,0 +1,35 @@ +/** + * Unit tests for the server-safe content extractors (contentSchema.ts). + * Run via: `npm run test:unit`. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { detectCodeLanguages, extractFaqs } from "../../utils/contentSchema"; + +test("detectCodeLanguages dedupes and normalizes language- classes", () => { + const html = `
`;
+  const langs = detectCodeLanguages(html);
+  assert.deepEqual([...langs].sort(), ["Go", "Python"]);
+});
+
+test("detectCodeLanguages returns empty for no code", () => {
+  assert.deepEqual(detectCodeLanguages("

no code here

"), []); + assert.deepEqual(detectCodeLanguages(undefined), []); +}); + +test("extractFaqs pulls question headings + answers, needs >= 2", () => { + const html = ` +

What is API testing?

It validates API behavior against expectations.

+

How does Keploy record traffic?

It captures real calls and turns them into test cases automatically.

+ `; + const faqs = extractFaqs(html); + assert.equal(faqs.length, 2); + assert.equal(faqs[0].question, "What is API testing?"); + assert.ok(faqs[0].answer.includes("validates API behavior")); +}); + +test("extractFaqs returns empty when fewer than 2 question headings", () => { + assert.deepEqual(extractFaqs("

What is X?

An answer long enough here.

"), []); + assert.deepEqual(extractFaqs("

Not a question

body

"), []); +}); diff --git a/utils/contentSchema.ts b/utils/contentSchema.ts new file mode 100644 index 00000000..e6c39366 --- /dev/null +++ b/utils/contentSchema.ts @@ -0,0 +1,85 @@ +/** + * Server-safe extractors that turn raw WordPress post HTML into structured-data + * inputs. Regex-only (NO `document`) so they run in getStaticProps / SSR, unlike + * the client-only extractAuthorData. Conservative by design — they return empty + * when confidence is low so we never emit misleading schema. + */ + +const LANG_ALIASES: Record = { + js: "JavaScript", + javascript: "JavaScript", + jsx: "JavaScript", + ts: "TypeScript", + typescript: "TypeScript", + tsx: "TypeScript", + py: "Python", + python: "Python", + go: "Go", + golang: "Go", + rs: "Rust", + rust: "Rust", + java: "Java", + rb: "Ruby", + ruby: "Ruby", + php: "PHP", + c: "C", + cpp: "C++", + cs: "C#", + sql: "SQL", + bash: "Shell", + sh: "Shell", + shell: "Shell", + zsh: "Shell", + yaml: "YAML", + yml: "YAML", + json: "JSON", + html: "HTML", + css: "CSS", + dockerfile: "Dockerfile", +}; + +/** Distinct human-readable programming languages present in a post's code blocks. */ +export function detectCodeLanguages(html: string | undefined | null): string[] { + if (!html) return []; + const found = new Set(); + const re = /language-([a-z0-9+#]+)/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(html)) !== null) { + const norm = LANG_ALIASES[m[1].toLowerCase()]; + if (norm) found.add(norm); + } + return Array.from(found); +} + +function stripTags(s: string): string { + return s + .replace(/<[^>]*>/g, " ") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Extract FAQ Q&A pairs: a heading (h2–h4) whose text ends in "?", followed by + * the prose up to the next heading. Only returns a set when at least 2 real + * pairs are found, so ordinary posts don't get a spurious FAQPage. + */ +export function extractFaqs( + html: string | undefined | null, + max = 10, +): { question: string; answer: string }[] { + if (!html) return []; + const faqs: { question: string; answer: string }[] = []; + const re = /]*>([\s\S]*?)<\/h[2-4]>([\s\S]*?)(?=]*>|$)/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(html)) !== null && faqs.length < max) { + const question = stripTags(m[1]); + if (!question.endsWith("?")) continue; + const answer = stripTags(m[2]); + if (question.length > 8 && answer.length > 20) { + faqs.push({ question, answer: answer.slice(0, 900) }); + } + } + return faqs.length >= 2 ? faqs : []; +} From 937e5da892f44b56ba8bcbba8ff60760be37d579 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 16:01:09 +0530 Subject: [PATCH 18/66] feat(schema): wire FAQPage, SoftwareSourceCode, DefinedTermSet, dependencies, speakable into post templates Signed-off-by: dhananjay6561 --- pages/community/[slug].tsx | 29 +++++++++++++++++++++++++++++ pages/technology/[slug].tsx | 30 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/pages/community/[slug].tsx b/pages/community/[slug].tsx index 071478b4..40738904 100644 --- a/pages/community/[slug].tsx +++ b/pages/community/[slug].tsx @@ -27,10 +27,15 @@ import "./styles.module.css" import { getBlogPostingSchema, getBreadcrumbListSchema, + getFAQPageSchema, + getSoftwareSourceCodeSchema, + getDefinedTermSetSchema, SITE_URL, } from "../../lib/structured-data"; import { sanitizeTitle, getSafeDescription, buildPageTitle } from "../../utils/seo"; import { getHowToSchema } from "../../lib/howToSchema"; +import { detectCodeLanguages, extractFaqs } from "../../utils/contentSchema"; +import { getTooltipsForSlug } from "../../config/keyword-tooltips"; const PostBody = dynamic(() => import("../../components/post-body")); @@ -158,6 +163,9 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) { const safeDescription = getSafeDescription(router.isFallback, post?.seo?.metaDesc, safeTitle); const postUrl = post?.slug ? `${SITE_URL}/community/${post.slug}` : `${SITE_URL}/community`; + const codeLanguages = detectCodeLanguages(post?.content); + const faqs = extractFaqs(post?.content); + const tooltipTerms = post?.slug ? getTooltipsForSlug(post.slug) : []; const structuredData = []; if (post?.slug) { structuredData.push( @@ -181,6 +189,10 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) { // LIVE-22: emit reviewedBy Person schema when reviewer data is // present. The schema generator skips the emit when the reviewer // is "Reviewer" (placeholder) or equals the author (self-review). + // Populate dependencies from the post's actual code languages. + dependencies: codeLanguages.length ? codeLanguages : undefined, + // Voice-assistant spoken summary target. + speakableSelectors: ["h1"], reviewerName: reviewAuthorName || undefined, reviewerImage: reviewAuthorImageUrl || undefined, reviewerDescription: reviewAuthorDescription || undefined, @@ -190,6 +202,23 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) { if (howTo) { structuredData.push(howTo); } + if (faqs.length) { + structuredData.push(getFAQPageSchema(faqs)); + } + for (const language of codeLanguages) { + structuredData.push( + getSoftwareSourceCodeSchema({ language, url: postUrl, name: `${safeTitle} — ${language} example` }), + ); + } + if (tooltipTerms.length) { + structuredData.push( + getDefinedTermSetSchema({ + name: `${safeTitle} — glossary`, + url: postUrl, + terms: tooltipTerms.map((t) => ({ term: t.keyword, description: t.heading })), + }), + ); + } } else { structuredData.push( getBreadcrumbListSchema([ diff --git a/pages/technology/[slug].tsx b/pages/technology/[slug].tsx index 4605a7d8..14cbbaa8 100644 --- a/pages/technology/[slug].tsx +++ b/pages/technology/[slug].tsx @@ -25,10 +25,15 @@ import { getRedirectSlug, hasRedirect } from "../../config/redirect"; import { getBlogPostingSchema, getBreadcrumbListSchema, + getFAQPageSchema, + getSoftwareSourceCodeSchema, + getDefinedTermSetSchema, SITE_URL, } from "../../lib/structured-data"; import { sanitizeTitle, getSafeDescription, buildPageTitle } from "../../utils/seo"; import { getHowToSchema } from "../../lib/howToSchema"; +import { detectCodeLanguages, extractFaqs } from "../../utils/contentSchema"; +import { getTooltipsForSlug } from "../../config/keyword-tooltips"; const PostBody = dynamic(() => import("../../components/post-body")); @@ -132,6 +137,9 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) { const safeDescription = getSafeDescription(router.isFallback, post?.seo?.metaDesc, safeTitle); const postUrl = post?.slug ? `${SITE_URL}/technology/${post.slug}` : `${SITE_URL}/technology`; + const codeLanguages = detectCodeLanguages(post?.content); + const faqs = extractFaqs(post?.content); + const tooltipTerms = post?.slug ? getTooltipsForSlug(post.slug) : []; const structuredData = []; if (post?.slug) { structuredData.push( @@ -156,6 +164,11 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) { // for technical queries. categorySlug: "technology", proficiencyLevel: "Intermediate", + // Populate TechArticle.dependencies from the code languages actually + // present in the post (was defined but never set). + dependencies: codeLanguages.length ? codeLanguages : undefined, + // Let a voice assistant read the post title as the spoken summary. + speakableSelectors: ["h1"], // LIVE-22: emit reviewedBy Person schema. Skipped by the // generator when the reviewer equals the author or when the // name falls back to the "Reviewer" placeholder. @@ -168,6 +181,23 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) { if (howTo) { structuredData.push(howTo); } + if (faqs.length) { + structuredData.push(getFAQPageSchema(faqs)); + } + for (const language of codeLanguages) { + structuredData.push( + getSoftwareSourceCodeSchema({ language, url: postUrl, name: `${safeTitle} — ${language} example` }), + ); + } + if (tooltipTerms.length) { + structuredData.push( + getDefinedTermSetSchema({ + name: `${safeTitle} — glossary`, + url: postUrl, + terms: tooltipTerms.map((t) => ({ term: t.keyword, description: t.heading })), + }), + ); + } } else { structuredData.push( getBreadcrumbListSchema([ From a3791715aef1b5d1a31f6eb0b557ce68c0dbff94 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 16:02:07 +0530 Subject: [PATCH 19/66] feat(schema): emit SearchResultsPage on /search and /community/search Signed-off-by: dhananjay6561 --- pages/community/search.tsx | 3 ++- pages/search.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pages/community/search.tsx b/pages/community/search.tsx index a4eb41e4..4d997289 100644 --- a/pages/community/search.tsx +++ b/pages/community/search.tsx @@ -10,7 +10,7 @@ import { getAllPostsForSearch } from "../../lib/api"; import { Post } from "../../types/post"; import { HOME_OG_IMAGE_URL } from "../../lib/constants"; import { getExcerpt } from "../../utils/excerpt"; // Importing from utils instead of inline -import { getBreadcrumbListSchema, SITE_URL } from "../../lib/structured-data"; +import { getBreadcrumbListSchema, getSearchResultsPageSchema, SITE_URL } from "../../lib/structured-data"; import { REVALIDATE_CONTENT } from "../../lib/isr"; export default function CommunitySearch({ allPosts }: { allPosts: { node: Post }[] }) { @@ -61,6 +61,7 @@ export default function CommunitySearch({ allPosts }: { allPosts: { node: Post } { name: "Community", url: `${SITE_URL}/community` }, { name: "Search", url: `${SITE_URL}/community/search` }, ]), + getSearchResultsPageSchema({ url: `${SITE_URL}/community/search`, query: searchTerm }), ]; return ( diff --git a/pages/search.tsx b/pages/search.tsx index eff93a6c..15387bb8 100644 --- a/pages/search.tsx +++ b/pages/search.tsx @@ -6,7 +6,7 @@ import MoreStories from "../components/more-stories"; import { getAllPostsForSearch } from "../lib/api"; // This now exists import { Post } from "../types/post"; import { HOME_OG_IMAGE_URL } from "../lib/constants"; -import { getBreadcrumbListSchema, SITE_URL } from "../lib/structured-data"; +import { getBreadcrumbListSchema, getSearchResultsPageSchema, SITE_URL } from "../lib/structured-data"; import { REVALIDATE_CONTENT } from "../lib/isr"; export default function SearchPage({ allPosts }: { allPosts: { node: Post }[] }) { @@ -23,6 +23,7 @@ export default function SearchPage({ allPosts }: { allPosts: { node: Post }[] }) { name: "Home", url: SITE_URL }, { name: "Search", url: `${SITE_URL}/search` }, ]), + getSearchResultsPageSchema({ url: `${SITE_URL}/search`, query }), ]; return ( From b52aa7b00dde5e6c69190de867174bdb70353383 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 16:03:30 +0530 Subject: [PATCH 20/66] feat(seo): add visible intro prose to archive templates (lifts thin-content, citable by AI) Signed-off-by: dhananjay6561 --- pages/community/index.tsx | 6 +++++- pages/tag/[slug].tsx | 6 +++++- pages/technology/index.tsx | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/pages/community/index.tsx b/pages/community/index.tsx index eab3042a..f5741b34 100644 --- a/pages/community/index.tsx +++ b/pages/community/index.tsx @@ -56,9 +56,13 @@ export default function Community({ allPosts: { edges, pageInfo }, preview }) {
-

+

Keploy Community Blog

+

+ Developer stories, open-source contributions, API testing tutorials, and + hands-on engineering guides from the Keploy community. +

{/* */} {heroPost && (
-

+

{tagDisplay} posts

+

+ Browse all Keploy blog posts tagged "{tagDisplay}" — tutorials, + guides, and expert insights on {tagDisplay} for developers and QA engineers. +

diff --git a/pages/technology/index.tsx b/pages/technology/index.tsx index a1564ec1..e890eb36 100644 --- a/pages/technology/index.tsx +++ b/pages/technology/index.tsx @@ -45,9 +45,13 @@ export default function Index({ allPosts: { edges, pageInfo }, preview }) {
-

+

Keploy Technology Blog

+

+ In-depth articles on API testing, test automation, CI/CD pipelines, + eBPF-based testing, and modern software quality engineering. +

{/* */} {heroPost && ( Date: Fri, 7 Aug 2026 16:35:37 +0530 Subject: [PATCH 21/66] feat(schema): homepage ItemList of featured posts; drop no-op single-item breadcrumb Signed-off-by: dhananjay6561 --- pages/index.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/pages/index.tsx b/pages/index.tsx index f7a6cca7..0516737e 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -11,8 +11,8 @@ import Testimonials from "../components/testimonials"; import Image from "next/image"; import OpenSourceVectorPng from "../public/images/open-source-vector.png"; import { - getBreadcrumbListSchema, getWebSiteSchema, + getItemListSchema, SITE_URL, } from "../lib/structured-data"; import { REVALIDATE_CONTENT } from "../lib/isr"; @@ -23,10 +23,22 @@ const BLOG_TITLE = "Keploy Blog — API Testing, Test Automation & eBPF Deep-Dives"; export default function Index({ communityPosts, technologyPosts, preview }) { - // Organization schema is in _document.tsx (global) — not duplicated here + // Organization schema is in _document.tsx (global) — not duplicated here. + // No BreadcrumbList: a single "Home" item is a no-op that SEMrush/Google flag, + // so the home route just carries WebSite + an ItemList of the featured posts. + const featuredItems = [ + ...(communityPosts || []).map(({ node }: any) => ({ + url: `${SITE_URL}/community/${node.slug}`, + name: node.title, + })), + ...(technologyPosts || []).map(({ node }: any) => ({ + url: `${SITE_URL}/technology/${node.slug}`, + name: node.title, + })), + ]; const structuredData = [ getWebSiteSchema(), - getBreadcrumbListSchema([{ name: "Home", url: SITE_URL }]), + getItemListSchema(featuredItems, "Recent Keploy blog posts"), ]; return ( From bd2f7c93adb1c220abbb631d2c95385306778f96 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 16:36:55 +0530 Subject: [PATCH 22/66] feat(schema): tag hub CollectionPage/ItemList of the tag directory Signed-off-by: dhananjay6561 --- pages/tag/index.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/pages/tag/index.tsx b/pages/tag/index.tsx index f830376f..b2a22d17 100644 --- a/pages/tag/index.tsx +++ b/pages/tag/index.tsx @@ -10,7 +10,7 @@ import { useMemo, useState } from "react"; import { FaSearch } from 'react-icons/fa'; import { useEffect, useRef } from "react"; import { getIconComponentForTag } from "../../utils/tagIcons"; -import { getBreadcrumbListSchema, SITE_URL } from "../../lib/structured-data"; +import { getBreadcrumbListSchema, getCollectionPageSchema, SITE_URL } from "../../lib/structured-data"; import { REVALIDATE_CONTENT } from "../../lib/isr"; export default function Tags({ edgesAllTags, preview }) { @@ -81,6 +81,17 @@ import { REVALIDATE_CONTENT } from "../../lib/isr"; { name: "Home", url: SITE_URL }, { name: "Tags", url: `${SITE_URL}/tag` }, ]), + getCollectionPageSchema({ + name: "Keploy Blog Tags", + url: `${SITE_URL}/tag`, + description: + "All topic tags on the Keploy blog — API testing, test automation, CI/CD, developer tools, and software quality.", + // Cap the directory at 150 entries so the JSON-LD payload stays lean. + items: (edgesAllTags || []).slice(0, 150).map(({ name }: { name: string }) => ({ + url: `${SITE_URL}/tag/${name}`, + name, + })), + }), ]} canonicalUrl={`${SITE_URL}/tag`} > From 03e8970c28166a37d707ef00e6f1e1dee3e80cc4 Mon Sep 17 00:00:00 2001 From: dhananjay6561 Date: Fri, 7 Aug 2026 16:38:16 +0530 Subject: [PATCH 23/66] fix(schema): drop raw inline JSON-LD from 404 (noindex page needs no breadcrumb) Signed-off-by: dhananjay6561 --- pages/404.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/pages/404.tsx b/pages/404.tsx index 8e645257..df21648f 100644 --- a/pages/404.tsx +++ b/pages/404.tsx @@ -4,8 +4,6 @@ import Head from "next/head"; import NotFoundPage from "../components/NotFoundPage"; import { getAllPostsForTechnology, getAllPostsForCommunity } from "../lib/api"; import { GetStaticProps } from "next"; -import { getBreadcrumbListSchema, SITE_URL } from "../lib/structured-data"; -import { safeJsonLdStringify } from "../utils/seo"; import { REVALIDATE_CONTENT, REVALIDATE_ERROR } from "../lib/isr"; interface Custom404Props { @@ -21,10 +19,6 @@ export default function Custom404({ }: Custom404Props) { const router = useRouter(); const asPath = router.asPath; - const structuredData = getBreadcrumbListSchema([ - { name: "Home", url: SITE_URL }, - { name: "Not Found", url: `${SITE_URL}${asPath || "/404"}` }, - ]); useEffect(() => { const redirectTimeout = setTimeout(() => { @@ -48,12 +42,6 @@ export default function Custom404({ name="description" content="Oops! The page you're looking for doesn't exist. Explore our latest blog posts and featured articles." /> - guard), so decoding here removes no real defence layer. Adds tests for both (bulleted answer captured; angle brackets decoded). Signed-off-by: dhananjay6561 --- tests/lib/contentSchema.test.ts | 24 ++++++++++++++++++++++ utils/contentSchema.ts | 35 ++++++++++++++++++++------------- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/tests/lib/contentSchema.test.ts b/tests/lib/contentSchema.test.ts index 8ba4dd6c..300445e0 100644 --- a/tests/lib/contentSchema.test.ts +++ b/tests/lib/contentSchema.test.ts @@ -68,6 +68,30 @@ test("extractFaqs answers skip code/tables, taking paragraph text only", () => { assert.ok(faqs[0].answer.includes("Run the installer")); }); +test("extractFaqs includes
  • list answers (WP FAQ sections often use bullets)", () => { + const html = ` +

    FAQ

    +

    What does Keploy record?

    • HTTP calls and their dependencies during a real run.
    +

    Does it need code changes?

    • No code changes are required to start recording.
    + `; + const faqs = extractFaqs(html); + assert.equal(faqs.length, 2); + assert.ok(faqs[0].answer.includes("HTTP calls"), "bulleted answer must be captured"); + assert.ok(faqs[1].answer.includes("No code changes")); +}); + +test("extractFaqs decodes </> so answers carry real angle brackets, not entities", () => { + const html = ` +

    FAQ

    +

    How do I start recording traffic?

    Run <keploy record> in your terminal to begin.

    +

    Is it open source?

    Yes, the core is open source on GitHub for everyone.

    + `; + const faqs = extractFaqs(html); + assert.equal(faqs.length, 2); + assert.ok(faqs[0].answer.includes(""), "angle brackets should be decoded"); + assert.ok(!faqs[0].answer.includes("<"), "no literal entity should remain"); +}); + test("extractFaqs returns empty for an FAQ marker with fewer than 2 pairs", () => { assert.deepEqual( extractFaqs("

    FAQ

    What is X?

    An answer long enough here to pass.

    "), diff --git a/utils/contentSchema.ts b/utils/contentSchema.ts index d4d375f3..9a5722e4 100644 --- a/utils/contentSchema.ts +++ b/utils/contentSchema.ts @@ -76,10 +76,12 @@ function stripTags(s: string): string { .replace(/&#x([0-9a-f]+);/gi, (_, n) => safeFromCodePoint(parseInt(n, 16))) .replace(/"/g, '"') .replace(/'/g, "'") - // Deliberately DO NOT decode < / >: leaving angle brackets encoded is - // the same defence-in-depth layer utils/seo.ts's decodeEntities documents, - // behind safeJsonLdStringify. Decoding them here would silently remove that - // layer for any text that flows into JSON-LD. See PR review #3. + // Decode < / > too: an AI citing an answer should read "", + // not the literal entity text. This runs AFTER tags are stripped, and every + // JSON-LD sink goes through safeJsonLdStringify (the hard guard), so + // decoding here is safe — it doesn't remove any real defence layer. + .replace(/</g, "<") + .replace(/>/g, ">") // & last so we never double-decode (e.g. "&#8217;" stays literal). .replace(/&/g, "&") .replace(/\s+/g, " ") @@ -100,7 +102,8 @@ const FAQ_SECTION_HEADING = /^(faqs?|faq's|frequently asked questions)$/i; * 1. find the marker heading (FAQ / Frequently Asked Questions), * 2. bound the section at the next heading of the same-or-higher level, * 3. inside it, each sub-heading ending in "?" is a question and the answer is - * the text of the following

    paragraphs only (code/tables/lists skipped). + * the text of the following

    paragraphs and

  • list items (code blocks + * and tables skipped, since WP FAQ answers are often bulleted lists). * Still requires ≥2 clean pairs, so a stray "FAQ" heading alone emits nothing. */ export function extractFaqs( @@ -149,16 +152,20 @@ export function extractFaqs( while ((q = qRe.exec(section)) !== null && faqs.length < max) { const question = stripTags(q[2]); if (!question.endsWith("?")) continue; - const paragraphs: string[] = []; - // `]*)?>` matches

    /

    but NOT

     — otherwise a
    -    // code block leaks into the answer (the exact flattening review #2 flagged).
    -    const pRe = /]*)?>([\s\S]*?)<\/p>/gi;
    -    let p: RegExpExecArray | null;
    -    while ((p = pRe.exec(q[3])) !== null) {
    -      const t = stripTags(p[1]);
    -      if (t) paragraphs.push(t);
    +    // Answer = text of 

    paragraphs AND

  • list items, in document order + // (WP FAQ sections often answer with a