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 = ({
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