From 81baac053c88f126e9f8edb796b65233cbb71bc6 Mon Sep 17 00:00:00 2001 From: Neha Gupta Date: Fri, 23 Oct 2026 21:26:27 +0530 Subject: [PATCH 1/2] fix(seo): titles, H1 casing, author box, schema publisher, Updated date, robots/sitemap cleanup - buildPageTitle clips at phrase boundaries (no dangling '&', ':', unclosed '(') - PostTitle: acronym-aware headline case (API, QA, CI/CD, gRPC), no cap after apostrophe, keep 'vs' - BlogPosting/TechArticle publisher: url + sameAs incl. Wikidata Q137395272; SoftwareApplication mention carries applicationCategory DeveloperApplication - Strip empty PublishPress author box (hardcoded box-post-id-16); writer card falls back to the WP user bio/avatar matched by exact name - Visible 'Updated ' when WP modified > published; byline/CTAs in ); } diff --git a/components/post-title.tsx b/components/post-title.tsx index 4d4474e9..967864c7 100644 --- a/components/post-title.tsx +++ b/components/post-title.tsx @@ -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 (

block) is removed from the body. +// Call extractAuthorBio on the RAW content first if you need its bio. +const PP_BOX_COMMENT_RE = /[\s\S]*?pp-multiple-authors[\s\S]*?/gi; +const PP_BOX_STYLE_RE = /]*>[^<]*pp-multiple-authors-boxes-wrapper[\s\S]*?<\/style>/gi; + +export function stripPublishPressAuthorBox(html?: string | null): string { + if (!html) return ""; + return html.replace(PP_BOX_COMMENT_RE, "").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 + ); +} diff --git a/lib/structured-data.ts b/lib/structured-data.ts index 92b06fc1..5eaef017 100644 --- a/lib/structured-data.ts +++ b/lib/structured-data.ts @@ -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 = { @@ -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 @@ -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; diff --git a/next.config.js b/next.config.js index c7d766e4..24047ac6 100644 --- a/next.config.js +++ b/next.config.js @@ -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: [ diff --git a/pages/community/[slug].tsx b/pages/community/[slug].tsx index 0bc58b2b..b7394286 100644 --- a/pages/community/[slug].tsx +++ b/pages/community/[slug].tsx @@ -16,6 +16,7 @@ import { getMoreStoriesForSlugs, getPostAndMorePosts, getReviewAuthors, + getReviewAuthorDetailsCached, } from "../../lib/api"; import ContainerSlug from "../../components/containerSlug"; import { useEffect, useMemo, useRef } from "react"; @@ -34,7 +35,11 @@ import { SITE_URL, } from "../../lib/structured-data"; import { sanitizeTitle, getSafeDescription, buildPageTitle } from "../../utils/seo"; -import { extractAuthorBio } from "../../lib/author-box"; +import { + extractAuthorBio, + pickWpUserForAuthor, + stripPublishPressAuthorBox, +} from "../../lib/author-box"; import { getHowToSchema } from "../../lib/howToSchema"; import { detectCodeLanguages, countWords, extractFaqs } from "../../utils/contentSchema"; import { getTooltipsForSlug } from "../../config/keyword-tooltips"; @@ -63,7 +68,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; @@ -94,7 +99,12 @@ 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); + const wpWriter = pickWpUserForAuthor(writerDetails, post?.ppmaAuthorName); + 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(); @@ -104,7 +114,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 @@ -279,6 +292,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} @@ -298,7 +312,7 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) {
null) + : null; return { props: { @@ -403,6 +422,7 @@ export const getStaticProps: GetStaticProps = async ({ post: data.post, posts: moreStories?.communityMoreStories || { edges: [] }, reviewAuthorDetails: authorDetails, + writerDetails: writerDetails ?? null, }, revalidate: REVALIDATE_CONTENT, }; diff --git a/pages/technology/[slug].tsx b/pages/technology/[slug].tsx index 540d548c..0bc1b15c 100644 --- a/pages/technology/[slug].tsx +++ b/pages/technology/[slug].tsx @@ -14,6 +14,7 @@ import { getMoreStoriesForSlugs, getPostAndMorePosts, getReviewAuthors, + getReviewAuthorDetailsCached, } from "../../lib/api"; import ContainerSlug from "../../components/containerSlug"; import { useRef, useEffect, useMemo } from "react"; @@ -32,7 +33,11 @@ import { SITE_URL, } from "../../lib/structured-data"; import { sanitizeTitle, getSafeDescription, buildPageTitle } from "../../utils/seo"; -import { extractAuthorBio } from "../../lib/author-box"; +import { + extractAuthorBio, + pickWpUserForAuthor, + stripPublishPressAuthorBox, +} from "../../lib/author-box"; import { getHowToSchema } from "../../lib/howToSchema"; import { detectCodeLanguages, countWords, extractFaqs } from "../../utils/contentSchema"; import { getTooltipsForSlug } from "../../config/keyword-tooltips"; @@ -50,7 +55,7 @@ const postBody = ({ content, post }) => { return replacedContent; }; -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; @@ -76,7 +81,12 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) { // 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); + const wpWriter = pickWpUserForAuthor(writerDetails, post?.ppmaAuthorName); + 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(); @@ -85,7 +95,10 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) { // Writer description — extract synchronously from post content (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 for any downstream reference to avatarImgSrc. @@ -258,6 +271,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} @@ -276,7 +290,8 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) {
null) + : null; // If we resolved a redirect slug, send a proper 301 redirect response if (redirectSlug) { @@ -387,6 +407,7 @@ export const getStaticProps: GetStaticProps = async ({ post: data.post, posts: moreStories?.techMoreStories || { edges: [] }, reviewAuthorDetails: authorDetails, + writerDetails: writerDetails ?? null, }, revalidate: REVALIDATE_CONTENT, }; diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index e18342b8..00000000 --- a/public/robots.txt +++ /dev/null @@ -1,106 +0,0 @@ -# Keploy blog robots.txt -# Policy: allow AI search/answer engines, block training-only crawlers, -# block Bytespider. Search bots drive visibility in ChatGPT, Claude, -# Perplexity, Copilot, Gemini answers. Training bots feed future model -# weights and provide nothing back. -# Reference: Speedscale / Katalon / Testsigma split policy (2026 competitor audit) - -# ============================================================================= -# ALLOW — AI search / answer engines -# Each named User-agent group only reads rules from THAT group — it does NOT -# fall through to `User-agent: *`. So the blog `basePath: '/blog'` Disallow -# rules must be repeated inside this group to prevent AI search bots from -# indexing /blog/wp/, /blog/api/, /blog/cgi-bin/. -# ============================================================================= - -User-agent: OAI-SearchBot -User-agent: ChatGPT-User -User-agent: Claude-SearchBot -User-agent: Claude-User -User-agent: PerplexityBot -User-agent: Perplexity-User -User-agent: Gemini-Deep-Research -User-agent: GoogleOther -User-agent: Applebot -User-agent: DuckAssistBot -User-agent: Amazonbot -Allow: / -Allow: /blog/api/proxy-image -Disallow: /blog/cgi-bin/ -Disallow: /blog/wp/ -Disallow: /blog/api/ - -# ============================================================================= -# DISALLOW — Training-only crawlers -# ============================================================================= - -User-agent: GPTBot -Disallow: / - -User-agent: ClaudeBot -Disallow: / - -User-agent: anthropic-ai -Disallow: / - -User-agent: CCBot -Disallow: / - -User-agent: Google-Extended -Disallow: / - -User-agent: Applebot-Extended -Disallow: / - -User-agent: Meta-ExternalAgent -Disallow: / - -User-agent: FacebookBot -Disallow: / - -User-agent: cohere-ai -Disallow: / - -User-agent: Diffbot -Disallow: / - -User-agent: Omgilibot -Disallow: / - -User-agent: ImagesiftBot -Disallow: / - -# Always-block scraper -User-agent: Bytespider -Disallow: / - -# ============================================================================= -# DEFAULT — Googlebot, Bingbot, and all other crawlers -# -# The Next.js app is mounted under basePath: '/blog' (next.config.js), so -# crawlers request /blog/api/*, /blog/wp/*, /blog/cgi-bin/*, etc. Paths -# in robots.txt are matched against the exact request path the crawler -# emits, so every Disallow/Allow here must include the /blog prefix. -# ============================================================================= - -User-agent: * -Allow: / -# Allow /blog/api/proxy-image so crawlers can render external avatars -# and remote images used throughout the blog UI. All other /blog/api/* -# endpoints remain disallowed below. -Allow: /blog/api/proxy-image -Disallow: /blog/cgi-bin/ -Disallow: /blog/wp/ -Disallow: /blog/api/ -# /blog/search is not disallowed here — pages/search.tsx already emits -# . Blocking via -# robots.txt would hide the noindex signal from crawlers and cause -# "Indexed, though blocked by robots.txt" warnings. Pick one strategy; -# we keep the page crawlable and rely on the meta tag. See -# https://developers.google.com/search/docs/crawling-indexing/block-indexing - -# ============================================================================= -# Sitemaps -# ============================================================================= - -Sitemap: https://keploy.io/blog/sitemap.xml diff --git a/scripts/generate-sitemap.mjs b/scripts/generate-sitemap.mjs index 0491c87d..eaeb64f5 100644 --- a/scripts/generate-sitemap.mjs +++ b/scripts/generate-sitemap.mjs @@ -378,8 +378,10 @@ function buildSitemapXml(entries) { ].join("\n "); const body = entries - .map(({ loc, lastmod, priority, image }) => { - let entry = ` \n ${escapeXml(loc)}\n ${escapeXml(lastmod)}\n ${priority}`; + // / are not emitted: Google ignores both. Only + // (each post's real WP `modified` date) is a used signal. + .map(({ loc, lastmod, image }) => { + let entry = ` \n ${escapeXml(loc)}\n ${escapeXml(lastmod)}`; if (image) { entry += `\n \n ${escapeXml(image.loc)}`; if (image.title) { diff --git a/tests/lib/titleCase.test.ts b/tests/lib/titleCase.test.ts new file mode 100644 index 00000000..65cba98a --- /dev/null +++ b/tests/lib/titleCase.test.ts @@ -0,0 +1,34 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { toHeadlineCase } from "../../utils/titleCase"; +import { buildPageTitle, clipTitleAtBoundary } from "../../utils/seo"; +import { stripPublishPressAuthorBox, pickWpUserForAuthor } from "../../lib/author-box"; + +test("acronyms, apostrophes and vs are preserved", () => { + assert.equal(toHeadlineCase("Playwright API Testing"), "Playwright API Testing"); + assert.equal(toHeadlineCase("playwright api testing"), "Playwright API Testing"); + assert.equal(toHeadlineCase("A Developer's Guide to API Mocking"), "A Developer's Guide To API Mocking"); + assert.equal(toHeadlineCase("Unit Testing vs Integration Testing"), "Unit Testing vs Integration Testing"); + assert.equal(toHeadlineCase("Top qa testing tools for ci/cd"), "Top QA Testing Tools For CI/CD"); + assert.equal(toHeadlineCase("gRPC and JSON in JavaScript (sdk)"), "gRPC And JSON In JavaScript (SDK)"); + assert.equal(toHeadlineCase("Debugging in vs code"), "Debugging In VS Code"); +}); + +test("title clip never ends mid-phrase", () => { + const out = clipTitleAtBoundary("Integration Testing: Complete Guide (Types, Tools & Examples) for 2026", 60); + assert.equal(out, "Integration Testing: Complete Guide"); + const out2 = buildPageTitle("A Developer's Guide to API Mocking: Benefits, Tools, and Best Practices"); + assert.ok(out2.length <= 60); + assert.ok(!/(,|and|&|:)$/.test(out2), out2); +}); + +test("PublishPress box is stripped, content kept", () => { + const html = `

Body

After

`; + assert.equal(stripPublishPressAuthorBox(html), "

Body

After

"); +}); + +test("WP user is matched by exact name only", () => { + const users = { edges: [{ node: { name: "Alok Kumar Singh" } }, { node: { name: "Alok Kumar", description: "bio" } }] }; + assert.equal(pickWpUserForAuthor(users, "alok kumar")?.description, "bio"); + assert.equal(pickWpUserForAuthor(users, "Neha"), null); +}); diff --git a/utils/seo.ts b/utils/seo.ts index 8cf47fb8..b2b52d8d 100644 --- a/utils/seo.ts +++ b/utils/seo.ts @@ -128,11 +128,33 @@ export function buildPageTitle(rawTitle: string | undefined | null): string { 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(" "); - // Always cut at the last word boundary in the window; hard-clip only when the - // window has no space at all (a single token longer than the limit). - return (lastSpace > 0 ? clipped.slice(0, lastSpace) : clipped).trimEnd(); + return clipTitleAtBoundary(base, MAX_TITLE_LENGTH); +} + +// Words/punctuation that must never end a clipped title: a title ending in +// "Tools &" or "Guide to" reads as broken in the SERP (audit 2026-10). +const DANGLING_TAIL_RE = + /(?:\s+(?:&|and|or|with|for|to|of|the|a|an|in|on|vs\.?|from|by|\+)|[\s,;:\-\u2013\u2014|(&+])+$/i; + +/** + * Clip a title to `limit` chars at a phrase/word boundary. Never leaves an + * unclosed "(" group or a dangling connector ("&", "and", "vs", ":") at the end. + */ +export function clipTitleAtBoundary(title: string, limit: number): string { + if (title.length <= limit) return title; + const window = title.slice(0, limit + 1); + const lastSpace = window.lastIndexOf(" "); + // Hard-clip only when the window has no space at all (one giant token). + let out = lastSpace > 0 ? window.slice(0, lastSpace) : title.slice(0, limit); + // Drop an unclosed parenthetical: "Guide (Types, Tools" -> "Guide". + const open = out.lastIndexOf("("); + if (open > 0 && out.indexOf(")", open) === -1) out = out.slice(0, open); + let prev; + do { + prev = out; + out = out.replace(DANGLING_TAIL_RE, "").trimEnd(); + } while (out !== prev && out.length > 0); + return out || title.slice(0, limit).trimEnd(); } /** diff --git a/utils/titleCase.ts b/utils/titleCase.ts new file mode 100644 index 00000000..c602c998 --- /dev/null +++ b/utils/titleCase.ts @@ -0,0 +1,60 @@ +// Headline title-casing for post H1s. The previous helper lowercased the whole +// string and upper-cased every \b\w, which produced "Api", "Qa", "Vs" and +// "Developer'S" (\b matches after an apostrophe). This version only touches the +// first letter of each word, keeps any word that already has an inner capital +// (API, gRPC, iOS, CI/CD, JavaScript), forces a known-acronym list to its +// canonical form, and keeps "vs" lowercase. + +const ACRONYMS: Record = {}; +[ + "API", "APIs", "QA", "AI", "ML", "LLM", "LLMs", "CI", "CD", "CI/CD", "SDK", + "SDKs", "JSON", "YAML", "XML", "UI", "UX", "HTTP", "HTTPS", "REST", "gRPC", + "SQL", "NoSQL", "CLI", "IDE", "E2E", "TDD", "BDD", "SaaS", "AWS", "GCP", + "K8s", "eBPF", "JWT", "OAuth", "URL", "URLs", "HTML", "CSS", "DevOps", + "SDLC", "MCP", "SOAP", "GraphQL", "CRUD", "DB", "OSS", "PR", "PRs", "VS", + "iOS", "macOS", "npm", "SRE", "SLA", "MVP", "ROI", "B2B", "TCP", "UDP", + "DNS", "RPC", "GUI", "JVM", "PDF", "CSV", "ORM", "QAs", +].forEach((word) => { + ACRONYMS[word.toLowerCase()] = word; +}); +// "vs" as in "Jest vs Mocha" stays lowercase; "VS Code" is handled below. +const LOWERCASE_WORDS = new Set(["vs", "vs."]); + +function caseWord(word: string, index: number, words: string[]): string { + if (!word) return word; + // Split off surrounding punctuation so "(API," still matches "api". + const match = word.match(/^([^A-Za-z0-9]*)(.*?)([^A-Za-z0-9]*)$/); + if (!match) return word; + const [, lead, core, trail] = match; + if (!core) return word; + const lower = core.toLowerCase(); + + if (LOWERCASE_WORDS.has(lower + trail) || LOWERCASE_WORDS.has(lower)) { + const next = words[index + 1]?.toLowerCase(); + if (lower === "vs" && next === "code") return `${lead}VS${trail}`; + return index === 0 ? word : `${lead}${lower}${trail}`; + } + if (ACRONYMS[lower]) return `${lead}${ACRONYMS[lower]}${trail}`; + // Plural/possessive acronyms: "API's", "APIs'". + const possessive = lower.match(/^(.+?)('s|’s|s'|s’)$/); + if (possessive && ACRONYMS[possessive[1]]) { + return `${lead}${ACRONYMS[possessive[1]]}${core.slice(possessive[1].length)}${trail}`; + } + // Leave mixed-case / all-caps words (JavaScript, gRPC, NodeJS) as written. + if (/[A-Z]/.test(core.slice(1)) || /^[a-z]+[A-Z]/.test(core)) { + return `${lead}${core}${trail}`; + } + // Capitalize only the first letter; never after an apostrophe, and keep + // the rest of the word exactly as authored ("Developer's", "don't"). + return `${lead}${core.charAt(0).toUpperCase()}${core.slice(1)}${trail}`; +} + +export function toHeadlineCase(input: string): string { + if (!input) return ""; + const words = input.split(/(\s+)/); + const tokens = words.filter((w) => !/^\s+$/.test(w)); + let i = 0; + return words + .map((w) => (/^\s+$/.test(w) ? w : caseWord(w, i++, tokens))) + .join(""); +} From 44b3a40166999d97a7f8fdff5b95c45f4cc3e60a Mon Sep 17 00:00:00 2001 From: Neha Gupta Date: Fri, 23 Oct 2026 21:26:27 +0530 Subject: [PATCH 2/2] fix(review): no author email in page props, safer author-box strip, title clip and acronym fixes - Select exact-name WP writer in getStaticProps and pass only name/avatar/bio (toPublicAuthor); drop email from the users query entirely - stripPublishPressAuthorBox matches each begin/end code block separately and removes only PublishPress ones - clipTitleAtBoundary keeps C++ (only strips standalone '+'), trims before the first unmatched '(' incl. nested and position 0 - titleCase acronym lookup uses a Map (no prototype keys) Co-Authored-By: Claude Opus 5.5 Signed-off-by: Neha Gupta --- lib/api.ts | 1 - lib/author-box.ts | 21 +++++++++++++++++++-- pages/community/[slug].tsx | 11 +++++++++-- pages/technology/[slug].tsx | 11 +++++++++-- tests/lib/titleCase.test.ts | 31 ++++++++++++++++++++++++++++++- utils/seo.ts | 21 ++++++++++++++++++--- utils/titleCase.ts | 11 ++++++----- 7 files changed, 91 insertions(+), 16 deletions(-) diff --git a/lib/api.ts b/lib/api.ts index 06939b0d..f2a140a2 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -239,7 +239,6 @@ export async function getReviewAuthorDetails(authorName) { edges { node { name - email avatar { url } diff --git a/lib/author-box.ts b/lib/author-box.ts index d711638c..ddf0719a 100644 --- a/lib/author-box.ts +++ b/lib/author-box.ts @@ -52,12 +52,17 @@ export function extractAuthorBox(html?: string): AuthorBoxMeta { // AuthorCard from ppmaAuthorName/ppmaAuthorImage + the WP user bio, so the // plugin markup (and its scoped