Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
be87ca1
feat(schema): give Organization a stable @id and link publisher nodes…
dhananjay6561 Aug 7, 2026
c605fde
fix(schema): always emit article image as ImageObject with OG fallback
dhananjay6561 Aug 7, 2026
f394942
fix(schema): guarantee a valid datePublished, never empty or invalid
dhananjay6561 Aug 7, 2026
1644c1d
fix(schema): sanitize description (strip HTML/entities) before emitting
dhananjay6561 Aug 7, 2026
dbc577f
fix(schema): fall back to a linked Keploy Team Person when author is …
dhananjay6561 Aug 7, 2026
29dd4b6
test(schema): assert Article stays valid for null/malformed WP inputs
dhananjay6561 Aug 7, 2026
46eb1ee
feat(seo): add on-page h1 to tag, community, and technology archive t…
dhananjay6561 Aug 7, 2026
8bfaadd
feat(seo): cap post <title> at 60 chars via buildPageTitle helper
dhananjay6561 Aug 7, 2026
5651d04
test(seo): pin buildPageTitle 60-char invariant; fix empty-title edge…
dhananjay6561 Aug 7, 2026
81a2554
fix(a11y): give cover-image link a non-empty aria-label and alt fallback
dhananjay6561 Aug 7, 2026
aec2eba
fix(a11y): fall back to default cover when WP featuredImage URL is mi…
dhananjay6561 Aug 7, 2026
cc741b9
feat(schema): add reusable getImageObjectSchema and use it for articl…
dhananjay6561 Aug 7, 2026
dfb459b
feat(schema): add getItemListSchema and getCollectionPageSchema builders
dhananjay6561 Aug 7, 2026
96bc97f
feat(schema): emit CollectionPage/ItemList on community, technology, …
dhananjay6561 Aug 7, 2026
65f94ff
feat(schema): wrap author in ProfilePage, enrich Person (image/sameAs…
dhananjay6561 Aug 7, 2026
6e36858
feat(schema): add FAQPage, DefinedTermSet, SoftwareSourceCode, Search…
dhananjay6561 Aug 7, 2026
92546c1
feat(schema): server-safe detectors for code languages and FAQ pairs
dhananjay6561 Aug 7, 2026
937e5da
feat(schema): wire FAQPage, SoftwareSourceCode, DefinedTermSet, depen…
dhananjay6561 Aug 7, 2026
a379171
feat(schema): emit SearchResultsPage on /search and /community/search
dhananjay6561 Aug 7, 2026
b52aa7b
feat(seo): add visible intro prose to archive templates (lifts thin-c…
dhananjay6561 Aug 7, 2026
3f26aa5
feat(schema): homepage ItemList of featured posts; drop no-op single-…
dhananjay6561 Aug 7, 2026
bd2f7c9
feat(schema): tag hub CollectionPage/ItemList of the tag directory
dhananjay6561 Aug 7, 2026
03e8970
fix(schema): drop raw inline JSON-LD from 404 (noindex page needs no …
dhananjay6561 Aug 7, 2026
736f19a
feat(schema): link post author/reviewer Person nodes to the author-pa…
dhananjay6561 Aug 7, 2026
388f2d1
feat(schema): attach ImageObject to ItemList entries (models listing …
dhananjay6561 Aug 7, 2026
dc3e534
chore(schema): delete 4 dead components; set author jobTitle to Author
dhananjay6561 Aug 7, 2026
8b87c38
feat(schema): add HowToTool (Keploy) to HowTo and an Occupation node …
dhananjay6561 Aug 7, 2026
3eaa5e1
fix(test): wait for the ISR fallback route before reading author JSON-LD
dhananjay6561 Aug 7, 2026
6641fc0
feat(schema): link the post author Person to the Keploy Organization …
dhananjay6561 Aug 7, 2026
66db353
docs(ai): document the JSON-LD coverage and entity @ids in llms.txt (…
dhananjay6561 Aug 7, 2026
53a383d
fix(schema): give the Blog and WebSite entities stable @ids
dhananjay6561 Aug 7, 2026
3b8f66e
feat(schema): model the home route as a CollectionPage
dhananjay6561 Aug 7, 2026
842012a
fix(seo): give the archive templates real titles (A9 title-too-short)
dhananjay6561 Aug 7, 2026
6b62768
feat(schema): emit community testimonials as Review nodes on the Kepl…
dhananjay6561 Aug 10, 2026
49a1f57
test(schema): pin getReviewSchema validity — rating-less, shared org …
dhananjay6561 Aug 10, 2026
dfe5780
feat(seo): add visible intro prose to the authors archive (A4 thin-co…
dhananjay6561 Aug 10, 2026
bacdb2f
fix(schema): url-less author fallback; make SoftwareSourceCode repo o…
dhananjay6561 Aug 10, 2026
5f55009
feat(schema): model Keploy as a SoftwareApplication + emit article co…
dhananjay6561 Aug 10, 2026
3d3f860
fix(schema): address review comments (valid schema, word-boundary tit…
dhananjay6561 Aug 10, 2026
3c190ee
fix(schema): harden entity decode, lang detection, tag + image URLs
dhananjay6561 Aug 10, 2026
9e884ed
fix(schema): drop ItemList entries with empty name/url (invalid ListI…
dhananjay6561 Aug 13, 2026
2627001
fix(schema): omit empty ItemList mainEntity on CollectionPage (orphan…
dhananjay6561 Aug 14, 2026
445663e
fix(a11y): add aria-label to author LinkedIn icon link (links-no-anchor)
dhananjay6561 Aug 14, 2026
edcbfab
fix(schema): guard getItemListSchema against empty itemListElement
dhananjay6561 Aug 14, 2026
57677ca
fix(schema): omit dates instead of fabricating new Date() (hydration …
dhananjay6561 Aug 15, 2026
04776ae
fix(schema): drop free/offers claim from SoftwareApplication (Keploy …
dhananjay6561 Aug 15, 2026
5495f4c
fix(schema): percent-encode tag slug in tag/[slug] URLs to match the …
dhananjay6561 Aug 15, 2026
05d723e
fix(schema): memoize content extractors; drop auto-FAQPage from artic…
dhananjay6561 Aug 15, 2026
1cdb21d
fix(schema): keep angle brackets encoded in stripTags (defence-in-depth)
dhananjay6561 Aug 15, 2026
a9bf2e1
test(schema): assert SoftwareApplication emits no price/free claim (r…
dhananjay6561 Aug 15, 2026
21c7236
fix(schema): drop SearchResultsPage from noindex search pages (dead m…
dhananjay6561 Aug 15, 2026
da5be34
fix(seo): route home <title> through buildPageTitle to hold the 60-ch…
dhananjay6561 Aug 15, 2026
dc414a4
feat(schema): re-add FAQPage behind an explicit FAQ-section marker
dhananjay6561 Aug 17, 2026
2932bb6
fix(sitemap): resolve redirects so no <loc> 301s (A8/A9 incorrect-pag…
dhananjay6561 Aug 17, 2026
1291b4b
fix(schema): include <li> FAQ answers, decode angle brackets in answers
dhananjay6561 Aug 17, 2026
e4b34bd
fix(sitemap): bucket lastmod by resolved category, guard resolver, ad…
dhananjay6561 Aug 17, 2026
42ef85b
fix(schema): bound FAQ section length against malformed markup
dhananjay6561 Aug 17, 2026
f24c43f
refactor(schema): single source of truth for JSON-LD builders
dhananjay6561 Aug 19, 2026
965dbef
feat(schema): add isAccessibleForFree, numberOfItems, WebSite publisher
dhananjay6561 Aug 19, 2026
ec43569
fix(sitemap): resolve + dedup static archive roots, bucket by resolve…
dhananjay6561 Aug 19, 2026
94c6774
fix(schema): stop emitting Review on the homepage (client-only wall)
dhananjay6561 Aug 19, 2026
8f4223f
fix(author): never render a broken avatar from junk WordPress data
dhananjay6561 Aug 19, 2026
339d760
chore(seo): refresh public/llms-full.txt content index
dhananjay6561 Aug 19, 2026
04f3484
fix(assets): align author avatar to author.webp (resolve cross-PR F1 …
dhananjay6561 Aug 20, 2026
4483325
feat: track banner impressions (for per-banner CTR) (#416)
dhananjay6561 Aug 25, 2026
a1023c1
Merge branch 'main' into ai-cite-health
nehagup Aug 25, 2026
ee87d3b
Merge branch 'main' into ai-cite-health
dhananjay6561 Aug 26, 2026
ba09062
fix(sitemap): fail loud when redirect config can't be loaded
dhananjay6561 Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 53 additions & 9 deletions lib/structured-data.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
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";
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).
Comment thread
dhananjay6561 marked this conversation as resolved.
export const ORG_ID = `${MAIN_SITE_URL}/#organization`;
Comment thread
dhananjay6561 marked this conversation as resolved.
// 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 =
Comment thread
dhananjay6561 marked this conversation as resolved.
"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/",
Expand Down Expand Up @@ -81,6 +96,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,
Expand Down Expand Up @@ -139,6 +155,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,
Expand All @@ -156,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
Expand All @@ -177,6 +201,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<string, unknown> = {
"@context": "https://schema.org",
"@type": schemaType,
Expand All @@ -185,11 +215,12 @@ export const getBlogPostingSchema = ({
"@type": "WebPage",
"@id": url,
},
datePublished,
dateModified: dateModified || datePublished,
datePublished: resolvedPublished,
dateModified: resolvedModified,
author: authorNode,
publisher: {
"@type": "Organization",
"@id": ORG_ID,
name: ORG_NAME,
logo: {
"@type": "ImageObject",
Expand Down Expand Up @@ -232,13 +263,25 @@ 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);
Comment thread
dhananjay6561 marked this conversation as resolved.
if (cleanDescription) {
schema.description = cleanDescription;
}
}

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.
Expand All @@ -263,6 +306,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: {
Expand Down
4 changes: 2 additions & 2 deletions pages/community/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -219,7 +219,7 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) {
<>
<article>
<Head>
<title>{`${post?.title || "Loading..."} | Keploy Blog`}</title>
<title>{buildPageTitle(post?.title)}</title>
{/* DM Sans + Baloo 2 are preloaded globally in _document.tsx */}
</Head>
<PostHeader
Expand Down
3 changes: 3 additions & 0 deletions pages/community/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export default function Community({ allPosts: { edges, pageInfo }, preview }) {
</Head>
<Header />
<Container>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight mt-4 mb-8">
Keploy Community Blog
</h1>
{/* <Intro /> */}
{heroPost && (
<HeroPost
Expand Down
3 changes: 3 additions & 0 deletions pages/tag/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export default function PostByTags({ postsByTags, preview, tagSlug: tagSlugProp
</Head>
<Header />
<Container>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight mt-4 mb-8">
{tagDisplay} posts
</h1>
<TagsStories posts={posts} />
</Container>
</Layout>
Expand Down
4 changes: 2 additions & 2 deletions pages/technology/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,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"));
Expand Down Expand Up @@ -197,7 +197,7 @@ export default function Post({ post, posts, reviewAuthorDetails, preview }) {
<>
<article>
<Head>
<title>{`${post?.title || "Loading..."} | Keploy Blog`}</title>
<title>{buildPageTitle(post?.title)}</title>
{/* DM Sans + Baloo 2 are preloaded globally in _document.tsx */}
</Head>
<PostHeader
Expand Down
3 changes: 3 additions & 0 deletions pages/technology/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export default function Index({ allPosts: { edges, pageInfo }, preview }) {
</Head>
<Header />
<Container>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight mt-4 mb-8">
Keploy Technology Blog
</h1>
{/* <Intro /> */}
{heroPost && (
<HeroPost
Expand Down
48 changes: 48 additions & 0 deletions tests/lib/seo.test.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
});
115 changes: 115 additions & 0 deletions tests/lib/structuredData.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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: "<p>Hello <strong>world</strong> &amp; more</p>",
});
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<string, unknown>;
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");
});
22 changes: 22 additions & 0 deletions utils/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,28 @@ export function sanitizeTitle(rawTitle: string | undefined | null): string {
return decodeEntitiesForAttribute(rawTitle);
}

const TITLE_SUFFIX = " | Keploy Blog";
const MAX_TITLE_LENGTH = 60;

/**
* Build a <title> 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();
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;
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.
Expand Down
Loading