Skip to content

feat(blog): site health + AI-citation fixes (A1/AI1 — harden Article schema) - #411

Open
dhananjay6561 wants to merge 67 commits into
keploy:mainfrom
dhananjay6561:ai-cite-health
Open

feat(blog): site health + AI-citation fixes (A1/AI1 — harden Article schema)#411
dhananjay6561 wants to merge 67 commits into
keploy:mainfrom
dhananjay6561:ai-cite-health

Conversation

@dhananjay6561

@dhananjay6561 dhananjay6561 commented Aug 7, 2026

Copy link
Copy Markdown
Member

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 noindex so they carry only a breadcrumb.)

Scope: blog repo only. Off main, independent of the image/font perf PR (#410). Delivered as micro atomic commits (52), including the round-1 review fixes below.


✅ Schema validation (the headline)

Built all pages and extracted every emitted JSON-LD block, validated against schema.org / Google Rich Results required + recommended fields:

1604 pages · 8656 JSON-LD blocks · every block parses · 0 errors · 0 warnings

Two real bugs were caught by this and fixed: an invalid ListItem with an empty name (null-title post) and an empty ItemList on orphan/0-post tags. @id reference stubs (e.g. {"@type":"Organization","@id":"…#organization"}) are valid JSON-LD and resolve to the canonical node.


📊 Ticket status

ID Area What Status
A1 / AI1 SEO AI Harden Article/TechArticle schema (image→ImageObject+fallback, dates resolved from post values and omitted when absent — never fabricated, url-less author fallback, sanitized description, wordCount) ✅ Done
A2 SEO On-page <h1> on tag / community / technology archives ✅ Done
A3 SEO buildPageTitle caps <title> ≤60 (word-boundary) ✅ Done
A4 / AI2 SEO AI Visible intro prose on all archive templates ✅ Done
A5 SEO cover-image fallback when WP URL missing ✅ Done (code part)
A6 / AI3 A11Y AI Non-empty aria-label/alt on cover-image links ✅ Done
A9 SEO Archive title-too-short fixes ✅ Done
AI4 AI HowTo(+HowToTool) + dependencies from detected code langs (FAQPage re-enabled behind an explicit in-post FAQ marker — see below) ✅ Done
AI5 AI Person/ProfilePage enrichment + @id entity linkage ✅ Done
AI6 AI llms.txt documents the JSON-LD coverage + entity @ids ✅ Done
Schema matrix AI ImageObject, ItemList, CollectionPage, ProfilePage, DefinedTermSet, SoftwareSourceCode, SoftwareApplication, SpeakableSpecification, Occupation, stable @ids ✅ Done
A7 / A8 SEO Redirects, internal-linking 🚧 Content/ops

✅ 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/Answer are re-enabled behind an explicit in-post FAQ marker after the round-1 review — see below. SearchResultsPage remains dropped from the noindex search pages. Review was dropped from the homepage — see "Not emitted" below.)

New in the latest revision:

  • Single source of truth — all JSON-LD construction is consolidated in lib/structured-data.ts: a shared SCHEMA_CONTEXT, orgReference()/blogReference() helpers for the @id-reference nodes, and getPersonSchema()/getProfilePageSchema() builders (the authors page no longer hand-rolls Person/ProfilePage inline; howToSchema.ts sources @context + ImageObject from the hub).
  • Three schema additionsisAccessibleForFree on articles (blog content is free, unlike the paid product), numberOfItems on ItemList/CollectionPage, and publisher + inLanguage on WebSite (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-author Person (…#person) — post publisher/author.worksFor/isPartOf all 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 required name/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. Emitting Review markup for content that isn't server-rendered is markup for invisible content, which Google penalizes. The getReviewSchema builder 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 the Review builder 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 the Review/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; the latest-post string in pages/404.tsx is a log message, not an import.

Two unused image assets go with them: public/images/author.png (replaced by public/images/author.webp) and public/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

# Sev Finding Fix
1 🔴 datePublished fell back to new Date() in the render body → ld+json hydration mismatch + a publish date that drifted on every ISR revalidate Resolve from the post's own dates only; omit when none are valid — a missing date is a soft warning, a fabricated one is invalid data
2 🟡 FAQPage auto-extracted from any heading ending in ? → flattened code/tables, 900-char clips, double page-type Re-enabled behind an explicit in-post FAQ marker with a level-aware section boundary; answers built from <p>/<li> text only (code/tables skipped), ≥2 clean pairs required
3 🟡 stripTags decoded &lt;/&gt; on a JSON-LD path → dropped the documented defence-in-depth layer Angle brackets are decoded in answer text (so an AI reads <keploy record>, not the entity); the </script> guarantee lives in safeJsonLdStringify, which every JSON-LD sink already goes through
4 🟡 Tag URL encoding inconsistent between the two tag routes (c#/tag/c%23 vs /tag/c#) Percent-encode the slug in tag/[slug] breadcrumb, CollectionPage.url, and canonical
5 🟡 detectCodeLanguages/countWords re-ran on every client render useMemo keyed on post content
6 🟡 SoftwareApplication claimed the product is free (isAccessibleForFree + offers price 0) despite paid tiers Dropped both; OSS core stays conveyed via downloadUrl + description
8 🟢 SearchResultsPage on noindex search pages was dead markup Removed from both search pages (kept only the breadcrumb)
9 🟢 Home <title> bypassed buildPageTitle Routed through it (no-op today at 60 chars, guard against future edits)

Not 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.txt hardcoded metrics) — flagged as a content/ops freshness follow-up, not a code fix. #12/#13 noted.


✅ Verification

  • npx tsc --noEmit → clean.
  • npm run test:unit87/87 pass (schema hardening incl. date-omission + no-price SoftwareApplication, title cap, content detectors, Review builder, @id linkage, isAccessibleForFree/numberOfItems/WebSite-publisher additions, FAQ <li>/angle-bracket extraction, and the sitemap redirect resolver incl. the archive-root R3 regression).
  • next build1600/1600 static pages, zero errors.
  • Full-site JSON-LD validation → 1604 pages, 8656 blocks, 0 errors / 0 warnings. Note: this run predates the round-1/2 fixes; those adjust the FAQ/SearchResultsPage blocks and are covered by tsc + the 82-test unit suite. A re-run before merge would only shift the block count, not introduce errors.
  • Post-merge: spot-check in the Rich Results Test.

… 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>
Copilot AI lite review requested due to automatic review settings August 7, 2026 10:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 getBlogPostingSchema with defensive fallbacks (always emit ImageObject, 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.

Comment thread lib/structured-data.ts
…emplates

Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
… case

Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
Copilot AI review requested due to automatic review settings August 7, 2026 10:17
Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
…ssing

Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
…e image

Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copilot AI review requested due to automatic review settings August 7, 2026 10:23
…id), add authored-works ItemList

Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts plus a unit-test guard, but this PR also changes page templates (adds <h1> and CollectionPage schema), adds buildPageTitle + 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 clipped unchanged). 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
});

Copilot AI review requested due to automatic review settings August 7, 2026 10:27
…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) === false only 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 url on schema.org ListItem objects. Elsewhere in this file (BreadcrumbList) ListItem uses the schema-standard item field for the URL, and using url here 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>
Copilot AI review requested due to automatic review settings August 19, 2026 09:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@dhananjay6561

Copy link
Copy Markdown
Member Author

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

  • truncation): left as is. the lazy match ends at the inner
  • so the outer tail drops, but regex genuinely cant balance nested lists and you already noted the content still reads fine. not worth a parser rewrite for a cosmetic trim.

    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.
    dhananjay6561 added a commit to dhananjay6561/blog-website that referenced this pull request Aug 19, 2026
    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.
    Copilot AI review requested due to automatic review settings August 19, 2026 10:31

    Copilot AI left a comment

    Copy link
    Copy Markdown
    Contributor

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

    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>
    Copilot AI review requested due to automatic review settings August 19, 2026 11:57

    Copilot AI left a comment

    Copy link
    Copy Markdown
    Contributor

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

    @dhananjay6561

    Copy link
    Copy Markdown
    Member Author

    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.

    @amaan-bhati

    Copy link
    Copy Markdown
    Member

    Claude Review Skill: Iteration 4

    At 95c33db (6 new commits). Reviewed from the diff. tsc / test:unit not run this round (no disk space on the review machine), so treat CI as the source of truth for those.

    🚦 Verdict: 🔄 REQUEST CHANGES

    🔴 blocking 🟡 important 🟢 nit 💡 suggestion 🎉 praise
    1 1 0 1 3

    ✅ Iteration 3 verified

    # Status
    R2 cross-PR Review vs ssr: false ✅ Dropped from the homepage, with the reasoning and the #410 reference written inline. getReviewSchema kept and tested so it can be re-wired if the wall returns to SSR. Correct call
    R3 static roots: wrong category, no dedup archiveRootCategory buckets by the resolved root, and dedup now runs against both staticSeen and the post seen set
    R5 FAQPage competing page type ⏭ Still open as a suggestion, unchanged

    Also resolved from earlier rounds: the hardcoded llms-full.txt metrics (17k stars, 1M installs, Gartner 4.6/5, 500+ articles) are gone, which closes Iteration 1 finding 7.

    🔴 Blocking

    F1. lib/constants.ts conflicts with #410, and the second merge breaks the build. Same finding as on #410, needs fixing once.

    #411 (this PR) #410
    thumbnil.png import still imported removed, file deleted
    AUTHOR_AVATAR_PLACEHOLDER /blog/images/author.png /blog/images/author.webp
    author.png in tree present deleted

    If #410 lands first, this branch reintroduces an import for a deleted file (module-not-found at build) and AUTHOR_AVATAR_PLACEHOLDER points at a deleted author.png. That is precisely the broken-avatar failure resolveAuthorAvatar was written to prevent, reintroduced through the placeholder path, and resolveAuthorAvatar will pass it through happily since it only checks that the value is root-relative. Neither branch's CI catches it alone.

    🟡 Important

    R1 (carried). Scope. Unchanged from Iteration 3, and this round added a JSON-LD builder refactor, an author-avatar fix, a sitemap change and an llms-full.txt rewrite on top. The sitemap resolver in particular is now well tested, which makes splitting it cheaper, not dearer.

    💡 Suggestion

    R5 (carried). FAQPage still sets url to the article URL. hasPart on the article would drop the competing page type.

    🎉 Praise

    • isAccessibleForFree on the article with the contrast spelled out against the SoftwareApplication builder that deliberately omits it. That is the distinction Iteration 1 finding 6 was actually about, and getting it right in one place while explaining the other is better than either change alone.
    • orgReference() / blogReference() / SCHEMA_CONTEXT do the thing @id de-fragmentation needs: byte-identical references, with a comment telling the next author to build from these rather than hand-rolling. That is what keeps the entity graph from drifting back apart.
    • resolveAuthorAvatar is a real bug caught outside the review loop, with the concrete failure named (ppmaAuthorImage of "imag1" becoming /_next/image?url=imag1 and a 400 on every post by that author) and a test alongside it.

    …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>
    Copilot AI review requested due to automatic review settings August 20, 2026 08:55

    Copilot AI left a comment

    Copy link
    Copy Markdown
    Contributor

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

    @dhananjay6561

    Copy link
    Copy Markdown
    Member Author

    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:

    • dropped the dead thumbnil.png import (kills the module not found half)
    • AUTHOR_AVATAR_PLACEHOLDER and every hardcoded ref now point at /blog/images/author.webp (constants, the structured data placeholder guard, the community + technology reviewer/author fallbacks, and the resolveAuthorAvatar test)
    • added public/images/author.webp (byte identical to perf: core web vitals #410) and removed author.png + thumbnil.png

    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 amaan-bhati left a comment

    Copy link
    Copy Markdown
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    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 @id now carries #faq, but its url is still the bare article URL, so it and the BlogPosting both present as the page at that URL. Dropping url (the #faq @id already identifies it) closes it.
    • Sitemap drop accounting. The script logs 525 posts as input and 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 reporting emitted N, skipped M, collapsed K would make a future 500 to 400 regression obvious, and this feeds the Search Console / IndexNow pipeline.

    Approving.

    Comment thread components/latest-post.tsx Outdated

    @nehagup nehagup left a comment

    Copy link
    Copy Markdown
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    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.tsheadline has no length cap. getBlogPostingSchema sets headline: title verbatim; Google recommends ≤110 chars and ignores Article rich results for over-long headlines. The PR caps the <title> but not headline, 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.js throws at load if WORDPRESS_API_URL is 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 — an import sits after a function decl (legal, hoisted, but out of place).
    • getSearchResultsPageSchema / getReviewSchema are 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.
    • speakableSelectors is 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 on main — 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.

    dhananjay6561 and others added 2 commits August 25, 2026 20:38
    * 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>
    Copilot AI review requested due to automatic review settings August 25, 2026 15:09

    Copilot AI left a comment

    Copy link
    Copy Markdown
    Contributor

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    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.`,
        );
    

    Comment on lines +195 to +197
    } catch (error) {
    console.warn("[generate-sitemap] Could not read vercel.json redirects:", error.message);
    }
    Comment thread pages/tag/[slug].tsx
    Comment on lines 16 to 22
    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';
    Copilot AI review requested due to automatic review settings August 26, 2026 17:16

    Copilot AI left a comment

    Copy link
    Copy Markdown
    Contributor

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

    Labels

    None yet

    Projects

    None yet

    Development

    Successfully merging this pull request may close these issues.

    4 participants