feat(blog): site health + AI-citation fixes (A1/AI1 — harden Article schema) - #411
feat(blog): site health + AI-citation fixes (A1/AI1 — harden Article schema)#411dhananjay6561 wants to merge 67 commits into
Conversation
… to it Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
…missing Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
There was a problem hiding this comment.
Pull request overview
Hardens the blog’s Article/TechArticle JSON-LD generator to defensively handle null/malformed WordPress fields so every post emits valid structured data (targeting the SEMrush “invalid structured data” flags), and adds unit-test coverage to prevent regressions.
Changes:
- Added stable Organization
@id(ORG_ID) and linked publishers to it to reduce entity fragmentation. - Hardened
getBlogPostingSchemawith defensive fallbacks (always emitImageObject, ISO date coercion, description sanitization, author fallback). - Added unit tests covering all-null/malformed inputs and key schema contracts.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
lib/structured-data.ts |
Adds schema fallbacks and stable Organization @id linkage to ensure per-post JSON-LD stays valid even with bad WP data. |
tests/lib/structuredData.test.ts |
Adds regression tests asserting required Article fields, fallbacks (image/date/author/description), and TechArticle behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…emplates Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
… case Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
…ssing Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
…e image Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (2)
tests/lib/seo.test.ts:31
- The “never mid-word” test isn’t actually validating a word-boundary truncation.
assert.ok(!/\S$/.test(out) === false)is equivalent to asserting the string ends with a non-space character, which doesn’t prove truncation occurred at a space boundary and could let regressions slip through.
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
});
tests/lib/seo.test.ts:5
- The PR description claims the scope is limited to
lib/structured-data.ts+ a unit-test guard (A1/AI1), but this PR also introduces A3 work (buildPageTitle+ tests) and A2 work (new<h1>on archive pages). Please update the PR description/scope (or split into separate PRs) so reviewers and release notes match what’s actually being shipped.
/**
* 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.
*/
Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
…tag, author listings Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
…id), add authored-works ItemList Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (3)
utils/seo.ts:125
- The PR description states the scope is limited to
lib/structured-data.tsplus a unit-test guard, but this PR also changes page templates (adds<h1>and CollectionPage schema), addsbuildPageTitle+ tests, and updates cover-image fallback behavior. This is a significant scope expansion relative to the stated “A1 / AI1 only” deliverable.
Please update the PR title/description (and the ticket/status table) to reflect the additional A2/A3 work, or split the non-A1 changes into separate PRs so reviewers can validate each SEO/a11y change independently.
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 {
utils/seo.ts:133
- The implementation can still truncate mid-word when the last space within the first 60 characters occurs at/before index 40 (it returns
clippedunchanged). That contradicts the function’s own docstring (“truncated at a word boundary”) and the unit test description.
Consider always truncating at the last space when one exists within the clipped range (falling back to a hard cut only when there are no spaces at all).
const clipped = base.slice(0, MAX_TITLE_LENGTH);
const lastSpace = clipped.lastIndexOf(" ");
return (lastSpace > 40 ? clipped.slice(0, lastSpace) : clipped).trimEnd();
tests/lib/seo.test.ts:31
- This assertion doesn’t verify “never mid-word” — it only re-states that the output ends with a non-space character (which is true for almost any non-empty title). As a result, the test will pass even if truncation happens mid-word.
Update the test to assert truncation happens on a space boundary in the original input (or update the test name if mid-word truncation is acceptable).
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
});
…ResultsPage builders + speakable support Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
…dencies, speakable into post templates Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (4)
utils/seo.ts:133
- buildPageTitle() docstring promises truncation at a word boundary, but the current logic returns the hard-clipped string when the last space is early (<=40), which can cut mid-word. This makes behavior inconsistent with the comment and the unit test intent.
const clipped = base.slice(0, MAX_TITLE_LENGTH);
const lastSpace = clipped.lastIndexOf(" ");
return (lastSpace > 40 ? clipped.slice(0, lastSpace) : clipped).trimEnd();
utils/seo.ts:125
- PR description says this PR’s scope is limited to
lib/structured-data.ts+ a unit-test guard for A1/AI1, and marks A2/A3 as planned. This PR also introduces A3 title-length logic (buildPageTitle + tests + usage) and adds archive H1 + CollectionPage schema across multiple pages; please update the PR description/ticket table (or split the extra work) so the stated scope matches the actual changes.
This issue also appears on line 131 of the same file.
/**
* 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 {
tests/lib/seo.test.ts:31
- This test claims it verifies “never mid-word”, but the current assertion
!(/\S$/).test(out) === falseonly checks that the output ends with a non-space (which is already covered by the previous assertion) and does not validate the word-boundary behavior.
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}`);
lib/structured-data.ts:196
- toListItems() uses the key
urlon schema.org ListItem objects. Elsewhere in this file (BreadcrumbList) ListItem uses the schema-standarditemfield for the URL, and usingurlhere risks emitting non-conforming ItemList/CollectionPage JSON-LD.
"@type": "ListItem",
position: index + 1,
url: it.url,
name: it.name,
}));
…d category The static archive-root entries resolved their loc through the redirect map but still keyed lastmod off the pre-redirect category and never deduped the resolved loc. If /blog/community ever gained a redirect it would emit a duplicate <loc> carrying the wrong category's lastmod — the same bug shape N3 fixed for posts, one layer up. Add archiveRootCategory() (categoryFromLoc stays scoped to post URLs), bucket by the resolved category, and dedup resolved static locs against each other and against post URLs. Covered by a named R3 regression test. Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
|
addressed iteration 3. pushed 22dbf81, 8a2975f, d3e330d. r3 (static roots): fixed. the static archive-root entries resolved their loc through the redirect map but still keyed lastmod off the pre-redirect category and never deduped the resolved loc. so if /blog/community ever gained a redirect you'd emit a duplicate with the wrong category's lastmod, the same shape as n3 one layer up. added archiveRootCategory (kept categoryFromLoc scoped to post urls since a test pins that), bucket by the resolved category now, and dedup resolved static locs against each other and against post urls. covered by a named r3 regression test. r4 (nested r5 (faq url vs hasPart): left as is for now. the @id #faq + isPartOf already disambiguates and validates clean, and isPartOf/hasPart are just inverse ways to state the same part-whole. happy to switch to hasPart if you'd rather only one node claim the url. r1 (carve out the sitemap resolver): keeping it in this pr. its tested and working, and splitting means a new branch + rebasing this on top + re-review for a modest gain. can carve it later if we want to diff sitemap.xml in isolation before publishing. r2 (cross-pr with #410): this pr is internally consistent, testimonials render server-side so the Review markup matches whats visible. the mismatch only shows up once #410 makes the marquee ssr:false, so that pr should keep testimonials ssr or drop the markup. nothing to change here. while in structured-data.ts also consolidated all json-ld into a single source of truth (context constant, shared org/blog @id reference helpers, Person/ProfilePage builders so the authors page stops hand-rolling them) and added three low-risk fields: isAccessibleForFree on articles, numberOfItems on ItemList/CollectionPage, and publisher+inLanguage on WebSite. tsc clean, test:unit 87/87 (was 82). |
The testimonials marquee is loaded client-only in PR keploy#410 (next/dynamic ssr:false) to keep the animating component off the LCP path, so the reviews are not in the server HTML a crawler reads. Emitting Review markup for content that isn't server-rendered is markup for invisible content, which Google penalizes. Drop the getReviewSchema call (and the Tweets import) from the homepage. The builder stays exported and unit-tested so it can be re-wired if the wall ever returns to SSR. Resolves the cross-PR concern (N3 here / R2 on Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com> keploy#411) without giving up keploy#410's ~0.5s LCP win.
The testimonials marquee is loaded client-only in PR keploy#410 (next/dynamic ssr:false) to keep the animating component off the LCP path, so the reviews are not in the server HTML a crawler reads. Emitting Review markup for content that isn't server-rendered is markup for invisible content, which Google penalizes. Drop the getReviewSchema call (and the Tweets import) from the homepage. The builder stays exported and unit-tested so it can be re-wired if the wall ever returns to SSR. Resolves the cross-PR concern (N3 here / R2 on Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com> keploy#411) without giving up keploy#410's ~0.5s LCP win.
Several WordPress authors carry junk in ppmaAuthorImage — the literal strings "imag1" / "image", "n/a", or empty (22 author bylines). Passing those to next/image renders /_next/image?url=imag1 -> HTTP 400 -> a broken byline avatar on every post those authors wrote. Add resolveAuthorAvatar() (+ AUTHOR_AVATAR_PLACEHOLDER) in lib/constants: anything that isn't a real http(s) URL or root-relative path collapses to the placeholder; a genuine URL passes through unchanged. Route the byline (community/technology [slug]), authors index (AuthorMapping), author hero (AuthorHero) and author card (AuthorCard) through it. Also stop junk from leaking into the JSON-LD author.image (schema now gets a real URL or none). Covered by tests/lib/resolveAuthorAvatar.test.ts. tsc + 91 unit tests pass. Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
|
pushed an author avatar fix (95c33db, plus the fix commit before it). the bug: ~22 author bylines were rendering a broken image. root cause was junk in wordpress, some authors have the literal strings "imag1" / "image" (or empty) in ppmaAuthorImage instead of a real photo. that value went straight into next/image, so /_next/image?url=imag1 returned a 400 and the byline avatar broke on every post those authors wrote. the authors index hid it because it had a brittle hardcoded check for exactly "imag1"/"image", so the same author looked fine there but broken on their post. fix: added resolveAuthorAvatar() + AUTHOR_AVATAR_PLACEHOLDER in lib/constants. anything thats not a real http(s) url or a root-relative path collapses to the placeholder, a genuine url passes through untouched. routed every avatar render path through it, the byline (community/technology [slug]), authors index (AuthorMapping), author hero (AuthorHero) and the bottom author card (AuthorCard). also stopped the junk leaking into the json-ld author.image, schema now gets a real url or nothing. verified on a real build: kanishak chaurasia and keploy team posts (both had junk) now show the clean placeholder at every spot, zero url=imag1 on the page, placeholder optimizes 200. covered by tests/lib/resolveAuthorAvatar.test.ts. the real content fix (uploading real photos for those 22, or clearing the junk field) is handed to the seo team for wordpress, this just makes the site resilient so bad data can never render a broken avatar again. tsc clean, test:unit 91/91. |
Claude Review Skill: Iteration 4At 🚦 Verdict: 🔄 REQUEST CHANGES
✅ Iteration 3 verified
Also resolved from earlier rounds: the hardcoded 🔴 BlockingF1.
If #410 lands first, this branch reintroduces an import for a deleted file (module-not-found at build) and 🟡 ImportantR1 (carried). Scope. Unchanged from Iteration 3, and this round added a JSON-LD builder refactor, an author-avatar fix, a sitemap change and an 💡 SuggestionR5 (carried). 🎉 Praise
|
…with keploy#410) keploy#410 migrates author.png -> author.webp and drops the dead thumbnil.png import. This branch still pointed at author.png, so whichever merged second broke: a module-not-found import (thumbnil.png deleted) and an avatar placeholder pointing at the deleted author.png. Align this branch to keploy#410's asset decisions so the two merge cleanly in any order: - drop the unused thumbnil.png import - AUTHOR_AVATAR_PLACEHOLDER + every hardcoded ref -> /blog/images/author.webp (constants, the structured-data placeholder guard, community/technology reviewer + author fallbacks, and the resolveAuthorAvatar test) - add public/images/author.webp (byte-identical to keploy#410), remove author.png and thumbnil.png tsc clean, test:unit 91/91, lint clean. Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
|
f1 (the cross pr constants + asset conflict): resolved from this side in 1a38e61. aligned this branch to #410's asset decisions so neither merge order breaks the build:
so both branches now agree: webp placeholder, no author.png, no thumbnil import. verified with git merge tree that the two landmines you flagged (an import of a deleted file, and a placeholder pointing at a deleted asset) are gone in both merge orders. the branches still have ordinary textual conflicts in the files they both touch (author components, constants, structured data), but those resolve by hand at merge now with no silent build break. tsc clean, test:unit 91/91, lint clean. r1 (scope) and r5 (faq hasPart) still noted, leaving as is for this pr. |
amaan-bhati
left a comment
There was a problem hiding this comment.
Approving. Verified locally at 1a38e61
Nothing failing: CI green on this head (build, lighthouse, E2E chromium, DCO), mergeable: MERGEABLE. Locally npm run test:unit 91/91, npx tsc --noEmit clean.
Went past the builders and checked the real output.
Sitemap, generated against live WordPress:
Generated public/sitemap.xml with 525 WordPress posts as input.
total <loc>: 521 duplicate <loc>: 0
non-https: 0 with #fragment: 0 double slashes: 0
lastmod: 521, malformed: 0
post locs: 518 (community 481 / technology 37)
non-post locs: 3 -> /blog, /blog/community, /blog/technology
Zero duplicate <loc> on 525 real posts is the R3 dedup holding outside fixtures.
JSON-LD actually emitted, three page shapes. Every block parses, @context is https://schema.org uniformly, @id graph stable across pages, no dangling references:
/blog WebSite @id=…/blog/#website · CollectionPage · Organization
@id=…/#organization · Blog @id=…/blog/#blog · SoftwareApplication
/blog/technology BreadcrumbList · CollectionPage · + the same @id nodes
/blog/community/… BreadcrumbList · BlogPosting · FAQPage · SoftwareSourceCode
numberOfItems accurate: /blog 6=6 /blog/technology 22=22
BlogPosting: 9/9 fields present, headline 53 chars
isAccessibleForFree=True (SoftwareApplication still omits it)
FAQPage: @id=…#faq, 10 questions, 0 missing an answer
R2 confirmed: no Review node anywhere in the emitted output.
💡 Two follow-ups, neither blocking
- R5, smaller than before. FAQPage
@idnow carries#faq, but itsurlis still the bare article URL, so it and the BlogPosting both present as the page at that URL. Droppingurl(the#faq@idalready identifies it) closes it. - Sitemap drop accounting. The script logs
525 posts as inputand emits 518 post locs, but never logs the output count or why any were dropped. The 7 are almost certainly uncategorised posts, correct by design, but that is my inference and I could not tell from the output. One line reportingemitted N, skipped M, collapsed Kwould make a future 500 to 400 regression obvious, and this feeds the Search Console / IndexNow pipeline.
Approving.
There was a problem hiding this comment.
Review — harden Article schema + site health
Recommendation: Approve with nits. Careful schema-hardening with unusually good unit tests. The changes that actually prevent Rich-Results rejections / manual-action risk are correct: dates resolved from post values only and omitted (never fabricated) when absent (fixes a real hydration/ISR-drift bug), no fabricated Rating/AggregateRating, image always emitted as an absolute-URL ImageObject, and descriptions tag-stripped/entity-decoded through safeJsonLdStringify (no </script> injection). Per-post author @id and the ProfilePage Person @id derive from the same slug so they merge; the url-less fallback author correctly carries no @id.
Schema is computed in the page data layer and the sitemap runs as a build script — neither adds runtime functions. The one caveat: it touched REVALIDATE_* constants. If any ISR revalidate interval was lowered, pages regenerate more often = more function invocations.
Should-fix
lib/structured-data.ts—headlinehas no length cap.getBlogPostingSchemasetsheadline: titleverbatim; Google recommends ≤110 chars and ignores Article rich results for over-long headlines. The PR caps the<title>but notheadline, and 113 posts were flagged long-title. Add a ≤110 truncation (or verify on Rich Results Test with a long-title post).scripts/generate-sitemap.mjs(loadRedirectMap) —next.config.jsthrows at load ifWORDPRESS_API_URLis unset; the dynamic import is in a try/catch that logs and continues, so a missing env var silently yields zero folded redirects → the sitemap can emit<loc>s that 301 (the exact issue this is meant to fix). Surface a louder failure or assert the redirect map is non-empty in the real build path.
Nits
pages/authors/[slug].tsx:36— animportsits after a function decl (legal, hoisted, but out of place).getSearchResultsPageSchema/getReviewSchemaare now dead exports (intentional, kept for re-wiring) — worth a comment so a future reader doesn't assume they're live.- Fallback author name differs:
"Keploy Team"(post schema) vs"Keploy Author"(authors/[slug].tsx:47) — cosmetic. speakableSelectorsis valid but Google's Speakable is news-only — aspirational, not a ranking lever.
Before merge (build safety)
- Grep-confirm the 4 deleted components (
ReviewingAuthor,TagsPostPreview,latest-post,post-preview) and 2 deleted images (author.png) have no remaining importers/references onmain— a stray import would 404/break the build.
Verify after deploy
Google Rich Results Test (long-title TechArticle, null-featuredImage post, an FAQ post, an author ProfilePage) and GSC Enhancements for new warnings; confirm sitemap <loc>s resolve to 200s (no 301s).
🤖 Assisted review via Claude Code.
* feat: track banner impressions for CTR Fire a viewable impression once each banner scrolls >=50% into view (IntersectionObserver, once per load): GA4 banner_impression event + Clarity banner_shown tag, both carrying banner_id. Pairs with the existing banner_click so we can compute real per-banner CTR (clicks / impressions) instead of raw clicks alone. Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com> * fix: only count a banner impression once the artwork paints Gate the impression on (loaded || errored) so a slow/blocked banner showing just the reserved skeleton slot doesn't count toward the CTR denominator — only a painted artwork (or the fallback card) counts. Add loaded/errored to the deps (with a comment) so the observer re-attaches when the artwork paints and to the swapped-in fallback node. Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com> * fix: abort the analytics-ready poller on unmount for impressions whenReady now takes an optional AbortSignal. The impression effect passes one and aborts it on cleanup, so if the reader SPA-navigates within the ~5s poll window (before gtag/clarity load), the poller is dropped instead of firing the event against the next page's context. Clicks are unchanged (fire-and-forget from onClick, component still mounted). Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com> * fix(banner): share impression state across the two sidebar instances Address review iteration 2 on banner impression tracking (F1, F2): - F1: the sidebar renders twice per page (desktop ≥1440 + a sub-1440 copy), so two SidebarAdBanner instances mount. Each previously picked its own banner and kept a per-instance impressionFired ref, so resizing across the 1440px breakpoint mounted the previously display:none instance, loaded its lazy image, and fired a SECOND impression for a DIFFERENT banner_id off one page view — inflating the denominator and understating CTR for both. Hoist the banner pick and the fire-once latch into lib/banner-rotation.ts (module scope, keyed per page view) so both instances agree on one banner and one impression, and a SPA nav re-picks + re-arms. - F2: impressions fire on scroll-into-view with no user action, so on wide desktop they can race Clarity's lazyOnload; the 5s give-up (fine for clicks, which follow a human decision) dropped impressions but never clicks, a one-sided loss that inflates CTR. Give impressions a much larger poll budget (~20s vs ~5s); safe because the poller is already aborted on unmount. - F3/F4/F6: note the latch fires on intersection, make the abort listener once-only, and soften the "A/B rotation" comment to "random rotation". - F5: unit-test the one-banner / one-impression-per-page invariant that F1 breaks (4 tests). 34 -> 38. Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com> * fix(banner): key impression pick on path without #fragment A TOC click uses history.replaceState directly, so router.asPath only picks up the hash on a later back/forward popstate. Keying the banner pick + impression latch on the hash re-picked the banner and re-armed the latch, firing a second impression for the same page view and inflating the CTR denominator. Split off the fragment before keying. Also document that the module-scoped pick/latch is safe only while the sidebar is ssr:false, and that claimImpression latches before send by design (latching on confirmed send re-opens the double-count). Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com> * fix(banner): reset paint gate on artwork change to decouple from _app remount Iteration-5 N1 flagged that loaded/errored never reset, so the impression gate could leak across SPA navs. In practice it doesn't reproduce: _app.tsx swaps <Component/> for <PageLoader/> on routeChangeStart, so the whole post subtree (including SidebarAdBanner) remounts on every nav and loaded/errored start false each post. The gate isn't bypassed and one image error doesn't pin the fallback for the session. But relying on a distant ancestor to remount us is fragile and would break silently if that swap ever changes. Reset the gate on banner?.src so the component stays correct on its own. Keyed on src (not banner) so re-picking the same banner keeps an already-painted image marked loaded. Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com> --------- Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 38 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
scripts/generate-sitemap.mjs:208
- Similarly, failing to load next.config.js redirects is currently only logged as a warning and then ignored, which can produce a sitemap that still contains redirecting URLs. It’s safer to fail sitemap generation when redirect sources can’t be loaded.
} catch (error) {
console.warn("[generate-sitemap] Could not load next.config.js redirects:", error.message);
}
scripts/generate-sitemap.mjs:229
- The redirect-cycle/hop-cap diagnostic uses console.warn, which introduces warning-level logging. Use console.error (or throw) here so the run is clearly flagged without adding warnings.
if (redirectMap.has(p)) {
console.warn(
`[generate-sitemap] redirect chain for ${loc} did not terminate (hop cap or cycle); emitting ${mainSiteUrl}${p}, which may still redirect.`,
);
| } catch (error) { | ||
| console.warn("[generate-sitemap] Could not read vercel.json redirects:", error.message); | ||
| } |
| const tagSlug = tagSlugProp || (Array.isArray(router.query.slug) ? router.query.slug[0] : (router.query.slug || '')); | ||
| // Percent-encode the slug for every emitted URL — the tag hub already does | ||
| // (pages/tag/index.tsx), so without this a tag like "c#" produces /tag/c%23 | ||
| // on the hub but a raw /tag/c# here, where everything after # is a fragment. | ||
| // The ItemList entry and the canonical would then disagree. | ||
| const encodedTagSlug = encodeURIComponent(tagSlug); | ||
| const tagDisplay = tagSlug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) || 'All Topics'; |
a33e35a to
a1023c1
Compare
Summary
Site Health + AI-citation / GEO overhaul for the blog, from the Aug 2026 SEMrush audit (Site Health 76%, AI Search Health 85%). This PR works the full ticket set A1–A9 and the AI plays AI1–AI6, and closes the entire schema-coverage matrix — every high-leverage schema.org type the blog was missing, wired into the indexable page types (home, listings, tag hub, post templates, author pages), then validated across the whole built site. (404 carries no JSON-LD by design; search pages are
noindexso they carry only a breadcrumb.)✅ Schema validation (the headline)
Built all pages and extracted every emitted JSON-LD block, validated against schema.org / Google Rich Results required + recommended fields:
Two real bugs were caught by this and fixed: an invalid
ListItemwith an emptyname(null-title post) and an emptyItemListon orphan/0-post tags.@idreference stubs (e.g.{"@type":"Organization","@id":"…#organization"}) are valid JSON-LD and resolve to the canonical node.📊 Ticket status
SEOAIwordCount)SEO<h1>on tag / community / technology archivesSEObuildPageTitlecaps<title>≤60 (word-boundary)SEOAISEOcover-imagefallback when WP URL missingA11YAIaria-label/alton cover-image linksSEOAIdependenciesfrom detected code langs (FAQPage re-enabled behind an explicit in-post FAQ marker — see below)AIPerson/ProfilePageenrichment +@identity linkageAIllms.txtdocuments the JSON-LD coverage + entity@idsAI@idsSEO✅ Schema types now emitted (validated live)
BlogPosting,TechArticle,Article,Blog,BreadcrumbList,CollectionPage,CreativeWork,DefinedTerm,DefinedTermSet,FAQPage,Question,Answer,HowTo,HowToStep,HowToTool,ImageObject,ItemList,ListItem,Occupation,Organization,ContactPoint,Person,ProfilePage,SearchAction,SoftwareApplication,SoftwareSourceCode,SpeakableSpecification,WebPage,WebSite.(
FAQPage/Question/Answerare re-enabled behind an explicit in-post FAQ marker after the round-1 review — see below.SearchResultsPageremains dropped from thenoindexsearch pages.Reviewwas dropped from the homepage — see "Not emitted" below.)New in the latest revision:
lib/structured-data.ts: a sharedSCHEMA_CONTEXT,orgReference()/blogReference()helpers for the@id-reference nodes, andgetPersonSchema()/getProfilePageSchema()builders (the authors page no longer hand-rolls Person/ProfilePage inline;howToSchema.tssources@context+ImageObjectfrom the hub).isAccessibleForFreeon articles (blog content is free, unlike the paid product),numberOfItemsonItemList/CollectionPage, andpublisher+inLanguageonWebSite(links the site to the one Keploy Organization@id).Entity graph is de-fragmented via stable
@ids:Organization(#organization),Blog(#blog),WebSite(#website),SoftwareApplication(#software), and per-authorPerson(…#person) — postpublisher/author.worksFor/isPartOfall reference these rather than duplicating.🧭 Not emitted — deliberate, with reasons
HowToSection/HowToTip/HowToSupply— require section/tip/supply structure WP content doesn't expose; emitting them means fabricating data.Clip/Dataset— plan flags these weak/N-A; no chaptered-video or dataset content exists.VideoObject— a bare WP<iframe>lacks the requiredname/thumbnailUrl/uploadDate; an incomplete VideoObject fails validation.Review— the homepage testimonials wall is loaded client-only in perf: core web vitals #410 (next/dynamic,ssr:false) to keep the animating marquee off the LCP path, so those reviews aren't in the server HTML a crawler reads. EmittingReviewmarkup for content that isn't server-rendered is markup for invisible content, which Google penalizes. ThegetReviewSchemabuilder stays exported and unit-tested so it can be re-wired if the wall returns to SSR. (Resolves the cross-PR concern with perf: core web vitals #410.)Rating/AggregateRating— self-rating markup is a Google manual-action risk; this is also why theReviewbuilder is rating-less.🚧 Not codeable here (WordPress / ops)
Re-uploading the 36 broken images (A5 data), redirect + internal-linking passes (A7/A8), WP post-content fixes (A9), and editorial FAQ/intro copy (A4) live in WordPress/ops.
🗑️ Removed files — why
Four orphaned components are deleted here. All four had zero importers on
main(verified by grep) — leftovers from an earlier blog layout that the current listing/post templates already replaced:components/ReviewingAuthor.tsx— rendered a "Reviewer Details" block and pointed at the now-removed/blog/images/author.png. Unused, and conceptually part of theReview/reviewer surface this PR deliberately stops emitting (see "Not emitted").components/post-preview.tsx,components/latest-post.tsx,components/TagsPostPreview.tsx— old post-card components superseded by the current archive templates. Nothing imports them; thelatest-poststring inpages/404.tsxis a log message, not an import.Two unused image assets go with them:
public/images/author.png(replaced bypublic/images/author.webp) andpublic/images/thumbnil.png(no references). No runtime code path changes — this just keeps the tree honest alongside the schema/image cleanup.🔁 Round-1 review — addressed
datePublishedfell back tonew Date()in the render body → ld+json hydration mismatch + a publish date that drifted on every ISR revalidateFAQPageauto-extracted from any heading ending in?→ flattened code/tables, 900-char clips, double page-type<p>/<li>text only (code/tables skipped), ≥2 clean pairs requiredstripTagsdecoded</>on a JSON-LD path → dropped the documented defence-in-depth layer<keploy record>, not the entity); the</script>guarantee lives insafeJsonLdStringify, which every JSON-LD sink already goes throughc#→/tag/c%23vs/tag/c#)tag/[slug]breadcrumb,CollectionPage.url, and canonicaldetectCodeLanguages/countWordsre-ran on every client renderuseMemokeyed on post contentSoftwareApplicationclaimed the product is free (isAccessibleForFree+offersprice 0) despite paid tiersdownloadUrl+ descriptionSearchResultsPageonnoindexsearch pages was dead markup<title>bypassedbuildPageTitleNot changed, on purpose: #10 (dead-component deletion is already in this PR's history; splitting it out now would just churn — the rationale is now written up under "🗑️ Removed files — why" above), and #7 (
llms-full.txthardcoded metrics) — flagged as a content/ops freshness follow-up, not a code fix. #12/#13 noted.✅ Verification
npx tsc --noEmit→ clean.npm run test:unit→ 87/87 pass (schema hardening incl. date-omission + no-price SoftwareApplication, title cap, content detectors,Reviewbuilder,@idlinkage,isAccessibleForFree/numberOfItems/WebSite-publisher additions, FAQ<li>/angle-bracket extraction, and the sitemap redirect resolver incl. the archive-root R3 regression).next build→ 1600/1600 static pages, zero errors.tsc+ the 82-test unit suite. A re-run before merge would only shift the block count, not introduce errors.