-
Notifications
You must be signed in to change notification settings - Fork 199
feat(blog): site health + AI-citation fixes (A1/AI1 — harden Article schema) #411
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dhananjay6561
wants to merge
68
commits into
keploy:main
Choose a base branch
from
dhananjay6561:ai-cite-health
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 c605fde
fix(schema): always emit article image as ImageObject with OG fallback
dhananjay6561 f394942
fix(schema): guarantee a valid datePublished, never empty or invalid
dhananjay6561 1644c1d
fix(schema): sanitize description (strip HTML/entities) before emitting
dhananjay6561 dbc577f
fix(schema): fall back to a linked Keploy Team Person when author is …
dhananjay6561 29dd4b6
test(schema): assert Article stays valid for null/malformed WP inputs
dhananjay6561 46eb1ee
feat(seo): add on-page h1 to tag, community, and technology archive t…
dhananjay6561 8bfaadd
feat(seo): cap post <title> at 60 chars via buildPageTitle helper
dhananjay6561 5651d04
test(seo): pin buildPageTitle 60-char invariant; fix empty-title edge…
dhananjay6561 81a2554
fix(a11y): give cover-image link a non-empty aria-label and alt fallback
dhananjay6561 aec2eba
fix(a11y): fall back to default cover when WP featuredImage URL is mi…
dhananjay6561 cc741b9
feat(schema): add reusable getImageObjectSchema and use it for articl…
dhananjay6561 dfb459b
feat(schema): add getItemListSchema and getCollectionPageSchema builders
dhananjay6561 96bc97f
feat(schema): emit CollectionPage/ItemList on community, technology, …
dhananjay6561 65f94ff
feat(schema): wrap author in ProfilePage, enrich Person (image/sameAs…
dhananjay6561 6e36858
feat(schema): add FAQPage, DefinedTermSet, SoftwareSourceCode, Search…
dhananjay6561 92546c1
feat(schema): server-safe detectors for code languages and FAQ pairs
dhananjay6561 937e5da
feat(schema): wire FAQPage, SoftwareSourceCode, DefinedTermSet, depen…
dhananjay6561 a379171
feat(schema): emit SearchResultsPage on /search and /community/search
dhananjay6561 b52aa7b
feat(seo): add visible intro prose to archive templates (lifts thin-c…
dhananjay6561 3f26aa5
feat(schema): homepage ItemList of featured posts; drop no-op single-…
dhananjay6561 bd2f7c9
feat(schema): tag hub CollectionPage/ItemList of the tag directory
dhananjay6561 03e8970
fix(schema): drop raw inline JSON-LD from 404 (noindex page needs no …
dhananjay6561 736f19a
feat(schema): link post author/reviewer Person nodes to the author-pa…
dhananjay6561 388f2d1
feat(schema): attach ImageObject to ItemList entries (models listing …
dhananjay6561 dc3e534
chore(schema): delete 4 dead components; set author jobTitle to Author
dhananjay6561 8b87c38
feat(schema): add HowToTool (Keploy) to HowTo and an Occupation node …
dhananjay6561 3eaa5e1
fix(test): wait for the ISR fallback route before reading author JSON-LD
dhananjay6561 6641fc0
feat(schema): link the post author Person to the Keploy Organization …
dhananjay6561 66db353
docs(ai): document the JSON-LD coverage and entity @ids in llms.txt (…
dhananjay6561 53a383d
fix(schema): give the Blog and WebSite entities stable @ids
dhananjay6561 3b8f66e
feat(schema): model the home route as a CollectionPage
dhananjay6561 842012a
fix(seo): give the archive templates real titles (A9 title-too-short)
dhananjay6561 6b62768
feat(schema): emit community testimonials as Review nodes on the Kepl…
dhananjay6561 49a1f57
test(schema): pin getReviewSchema validity — rating-less, shared org …
dhananjay6561 dfe5780
feat(seo): add visible intro prose to the authors archive (A4 thin-co…
dhananjay6561 bacdb2f
fix(schema): url-less author fallback; make SoftwareSourceCode repo o…
dhananjay6561 5f55009
feat(schema): model Keploy as a SoftwareApplication + emit article co…
dhananjay6561 3d3f860
fix(schema): address review comments (valid schema, word-boundary tit…
dhananjay6561 3c190ee
fix(schema): harden entity decode, lang detection, tag + image URLs
dhananjay6561 9e884ed
fix(schema): drop ItemList entries with empty name/url (invalid ListI…
dhananjay6561 2627001
fix(schema): omit empty ItemList mainEntity on CollectionPage (orphan…
dhananjay6561 445663e
fix(a11y): add aria-label to author LinkedIn icon link (links-no-anchor)
dhananjay6561 edcbfab
fix(schema): guard getItemListSchema against empty itemListElement
dhananjay6561 57677ca
fix(schema): omit dates instead of fabricating new Date() (hydration …
dhananjay6561 04776ae
fix(schema): drop free/offers claim from SoftwareApplication (Keploy …
dhananjay6561 5495f4c
fix(schema): percent-encode tag slug in tag/[slug] URLs to match the …
dhananjay6561 05d723e
fix(schema): memoize content extractors; drop auto-FAQPage from artic…
dhananjay6561 1cdb21d
fix(schema): keep angle brackets encoded in stripTags (defence-in-depth)
dhananjay6561 a9bf2e1
test(schema): assert SoftwareApplication emits no price/free claim (r…
dhananjay6561 21c7236
fix(schema): drop SearchResultsPage from noindex search pages (dead m…
dhananjay6561 da5be34
fix(seo): route home <title> through buildPageTitle to hold the 60-ch…
dhananjay6561 dc414a4
feat(schema): re-add FAQPage behind an explicit FAQ-section marker
dhananjay6561 2932bb6
fix(sitemap): resolve redirects so no <loc> 301s (A8/A9 incorrect-pag…
dhananjay6561 1291b4b
fix(schema): include <li> FAQ answers, decode angle brackets in answers
dhananjay6561 e4b34bd
fix(sitemap): bucket lastmod by resolved category, guard resolver, ad…
dhananjay6561 42ef85b
fix(schema): bound FAQ section length against malformed markup
dhananjay6561 f24c43f
refactor(schema): single source of truth for JSON-LD builders
dhananjay6561 965dbef
feat(schema): add isAccessibleForFree, numberOfItems, WebSite publisher
dhananjay6561 ec43569
fix(sitemap): resolve + dedup static archive roots, bucket by resolve…
dhananjay6561 94c6774
fix(schema): stop emitting Review on the homepage (client-only wall)
dhananjay6561 8f4223f
fix(author): never render a broken avatar from junk WordPress data
dhananjay6561 339d760
chore(seo): refresh public/llms-full.txt content index
dhananjay6561 04f3484
fix(assets): align author avatar to author.webp (resolve cross-PR F1 …
dhananjay6561 4483325
feat: track banner impressions (for per-banner CTR) (#416)
dhananjay6561 a1023c1
Merge branch 'main' into ai-cite-health
nehagup ee87d3b
Merge branch 'main' into ai-cite-health
dhananjay6561 ba09062
fix(sitemap): fail loud when redirect config can't be loaded
dhananjay6561 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}`); | ||
| } | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> & 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"); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.