diff --git a/README.md b/README.md index 2dc0f101..ff6f5948 100644 --- a/README.md +++ b/README.md @@ -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` | @@ -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 diff --git a/components/PostHeaderAuthors.tsx b/components/PostHeaderAuthors.tsx index 6b841daf..1b8248f0 100644 --- a/components/PostHeaderAuthors.tsx +++ b/components/PostHeaderAuthors.tsx @@ -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 = @@ -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 ( <> @@ -322,8 +328,13 @@ const PostHeaderAuthors = ({
- {formatPostDate(date)} + + {showUpdated && ( + + Updated + + )} {timetoRead} minutes diff --git a/components/post-header.tsx b/components/post-header.tsx index 67d4f26f..4e17c9e3 100644 --- a/components/post-header.tsx +++ b/components/post-header.tsx @@ -9,6 +9,7 @@ export default function PostHeader({ title, coverImage, date, + modified = "", author, categories, BlogWriter, @@ -31,13 +32,17 @@ export default function PostHeader({ {/* 3. Title */} {title} - {/* 4. Author card + social share icons */} -
+ {/* 4. Author card + social share icons. An
+
); } 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. +// 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\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(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 || "", + }; +} 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..4bc18aed 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,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"; @@ -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; @@ -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(); @@ -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 @@ -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} @@ -298,7 +314,7 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) {
null) + : null; + const writerDetails = toPublicAuthor( + pickWpUserForAuthor(writerUsers, data.post?.ppmaAuthorName), + ); return { props: { @@ -403,6 +429,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..bf750593 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,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"; @@ -50,7 +56,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 +82,13 @@ 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); + // 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(); @@ -85,7 +97,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 +273,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 +292,8 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) {
null) + : null; + const writerDetails = toPublicAuthor( + pickWpUserForAuthor(writerUsers, data.post?.ppmaAuthorName), + ); // If we resolved a redirect slug, send a proper 301 redirect response if (redirectSlug) { @@ -387,6 +414,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..a5491b0a --- /dev/null +++ b/tests/lib/titleCase.test.ts @@ -0,0 +1,63 @@ +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, toPublicAuthor } 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); +}); + +test("prototype keys are not acronyms", () => { + assert.equal(toHeadlineCase("the constructor pattern and toString"), "The Constructor Pattern And toString"); + assert.equal(toHeadlineCase("valueof hasownproperty"), "Valueof Hasownproperty"); +}); + +test("C++ survives clipping", () => { + assert.equal(clipTitleAtBoundary("Unit Testing in C++ Using GoogleTest and Catch2 Frameworks Today", 20), "Unit Testing in C++"); + assert.equal(clipTitleAtBoundary("Testing tips + tricks for great modern backend developers here", 15), "Testing tips"); +}); + +test("unmatched parens, nested and at position 0", () => { + assert.equal(clipTitleAtBoundary("Guide (Types (Unit, API), Tools and more stuff here", 30), "Guide"); + assert.equal(clipTitleAtBoundary("Guide (Types) and (More, Tools and stuff here", 30), "Guide (Types)"); + const out = clipTitleAtBoundary("(Draft) Testing (Unit, Integration, Contract, E2E things", 40); + assert.equal(out, "(Draft) Testing"); + const zero = clipTitleAtBoundary("(Unclosed testing guide for API and more words here", 30); + assert.ok(!zero.includes("("), zero); +}); + +test("strip keeps an earlier unrelated code block", () => { + const html = `
keep me

Mid

End

`; + assert.equal(stripPublishPressAuthorBox(html), `
keep me

Mid

End

`); +}); + +test("toPublicAuthor never carries email", () => { + const pub = toPublicAuthor({ name: "A", description: "b", avatar: { url: "u" }, email: "a@x.io" } as any); + assert.deepEqual(pub, { name: "A", avatar: { url: "u" }, description: "b" }); +}); diff --git a/utils/seo.ts b/utils/seo.ts index 8cf47fb8..eabe13e1 100644 --- a/utils/seo.ts +++ b/utils/seo.ts @@ -128,11 +128,48 @@ 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". + // Trim before the FIRST unmatched "(" (nested/multiple groups included). + const unmatched: number[] = []; + for (let i = 0; i < out.length; i++) { + if (out[i] === "(") unmatched.push(i); + else if (out[i] === ")" && unmatched.length) unmatched.pop(); + } + if (unmatched.length) { + // Unmatched "(" at the very start: trimming would empty the title, so + // drop just the stray bracket(s) instead. + out = + unmatched[0] > 0 + ? out.slice(0, unmatched[0]) + : out + .split("") + .filter((_, idx) => !unmatched.includes(idx)) + .join(""); + } + 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..3b81ccef --- /dev/null +++ b/utils/titleCase.ts @@ -0,0 +1,61 @@ +// 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. + +// Prototype-free so words like "constructor" never hit Object.prototype. +const ACRONYMS = new Map(); +[ + "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.set(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.has(lower)) return `${lead}${ACRONYMS.get(lower)}${trail}`; + // Plural/possessive acronyms: "API's", "APIs'". + const possessive = lower.match(/^(.+?)('s|’s|s'|s’)$/); + if (possessive && ACRONYMS.has(possessive[1])) { + return `${lead}${ACRONYMS.get(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(""); +}