Skip to content

feat(seo): show crawlers the plot, not the card it sits inside - #10482

Merged
MarkusNeusinger merged 3 commits into
mainfrom
feat/seo-expose-the-actual-render
Aug 18, 2026
Merged

feat(seo): show crawlers the plot, not the card it sits inside#10482
MarkusNeusinger merged 3 commits into
mainfrom
feat/seo-expose-the-actual-render

Conversation

@MarkusNeusinger

Copy link
Copy Markdown
Owner

The problem, seen rather than reasoned about

The bot page's only image was the og:image. Fetching both and looking at them:

Size What it actually shows
api.anyplot.ai/og/heatmap-annotated/python/altair.png 1200×630, 64 KB branding chrome around a ~380px thumbnail; the correlation values are barely legible
…/plot-light.png 2400×2400, 209 KB the plot, every cell label readable, colorbar ticked

The card is right for a shared link. It is the wrong thing to hand an assistant asked to show the plot — which is now a real use case: a live test had Claude find /heatmap-annotated/python/altair, read the source and return the image, and the image it could offer was the card.

What changed

The body now carries the actual render as a <picture>, both themes. og:image is untouched.

<picture>
  <source srcset="…plot-dark_400.png 400w, …_800.png 800w, …_1200.png 1200w"
          media="(prefers-color-scheme: dark)" />
  <img src="…plot-light_1200.png"
       srcset="…plot-light_400.png 400w, …_800.png 800w, …_1200.png 1200w"
       alt="Annotated Heatmap rendered with Altair" />
</picture>
<p><a href="…plot-light.png">Full-resolution render</a></p>

Three decisions worth stating

Attribution was not a reason to keep the card. I initially argued it was — the raw file sits on storage.googleapis.com, so an embedded render would have no visible tie to anyplot. That was wrong: every render's own title reads {spec} · {language} · {library} · anyplot.ai. The source travels with the image.

The full-size original is not in the srcset. Its width varies per plot — 2400, 3200 and 4766 among those measured — so no honest w descriptor exists for it. A wrong descriptor is worse than an absent one, so it gets a link instead.

src is the 1200px variant. A consumer that ignores srcset gets something that looks right without pulling a five-megapixel file.

Verification

  • The _400/_800/_1200 derivatives exist for every implementation checked — five spec/library/language combinations across Python, JavaScript, R and Julia, 25/25 present
  • Their suffix is the true pixel width, confirmed by reading PNG headers on square (2400×2400) and wide (3200×1800, 4766×2670) plots
  • Every URL the markup emits was fetched: all 200
  • pytest tests/unit — 1654 passed, six new tests including that the full-size is absent from the srcset and reachable through its link
  • ruff check + ruff format --check — clean

🤖 Generated with Claude Code

The bot page's only image was the 1200x630 og:image. In that card the
render is a thumbnail inside branding chrome — roughly a third of the
frame, cell labels barely legible. Right for a shared link, useless to an
assistant asked to show the plot, which is now a real use case rather
than a hypothetical one.

The body carries the actual render instead, as a <picture> with both
themes. Attribution is not the reason to prefer the card: every render's
own title reads "{spec} · {language} · {library} · anyplot.ai", so the
source travels with the image wherever it is embedded. og:image is
untouched — a shared link still gets the card it was designed for.

Sizes come from the _400/_800/_1200 derivatives the pipeline already
writes beside every render; their suffix is the true pixel width, checked
against the live files across square and wide plots in all four
languages, and every URL the markup emits was fetched and confirmed 200.

The full-size original is deliberately NOT in the srcset. Its width
varies per plot — 2400, 3200 and 4766 among those measured — so there is
no honest `w` descriptor for it, and a wrong one is worse than an absent
one. It gets its own link instead. src points at the 1200px variant, so
a consumer that ignores srcset gets something that looks right without
pulling a five-megapixel file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 18, 2026 21:11

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

This PR improves the SEO “bot” implementation pages so crawlers (and assistants) see the actual plot render in the page body instead of only the branded 1200×630 social card (og:image). This aligns the bot HTML with the real “show me the plot” use case while keeping social preview behavior unchanged.

Changes:

  • Add a <picture> block (light + optional dark) to implementation bot pages, using the pipeline-generated _400/_800/_1200 derivatives and linking to the full-resolution render.
  • Add unit tests covering srcset generation, default src, dark variant handling, and the full-resolution link.
  • Document the crawler-facing image behavior and add a changelog entry.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
api/routers/seo.py Adds _sized_srcset() + _render_picture() and switches implementation bot pages to embed plot renders in the body.
tests/unit/api/test_seo_helpers.py Adds tests for srcset derivation and <picture> HTML output behavior.
docs/reference/seo.md Documents the difference between social card images and body-embedded plot renders for crawlers.
CHANGELOG.md Records the behavior change under [Unreleased].
Suppressed comments (1)

api/routers/seo.py:513

  • The fallback <img> alt text is built from spec.title/lib_name via title_esc/lib_name_esc, but those are escaped without quote=True. A title containing quotes can break the alt="..." attribute context in the bot HTML. Escape the composed alt string with quote=True in the fallback branch.
    if impl.preview_url_light:
        plot_img = _render_picture(impl, f"{title_esc} rendered with {lib_name_esc}")
    else:
        plot_img = f'<img src="{image_esc}" alt="{title_esc} rendered with {lib_name_esc}" width="1200" height="630" />'

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread api/routers/seo.py
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.73684% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
api/routers/seo.py 94.73% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

The <picture> added a moment ago offers both themes, but only through a
media query: that tells a browser which file to take and a reader nothing
about which is which. And it says nothing at all about the interactive
version.

That version exists for 2,229 of 3,583 implementations — plotly, altair,
bokeh, pygal, lets-plot and every JavaScript library — is publicly
fetchable, returns text/html, and `preview_html` appeared nowhere in
api/routers/seo.py. Two thirds of the catalogue had an interactive
artefact that no machine reading the page could discover.

The page now lists each asset in words: full-resolution render light and
dark, interactive version light and dark, omitting whatever an
implementation does not have so a static library is never advertised as
interactive. All four URLs verified 200 against the live bucket.

og:image stays. It is not redundant with the body render — it is the
mechanism by which a shared link shows a picture at all, and removing it
would leave social previews with nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 18, 2026 21:18
@MarkusNeusinger

Copy link
Copy Markdown
Owner Author

Extended after a question from the owner: does the page note that light and dark exist, and that interactive libraries also produce .html?

The first was only implied — a <picture> media query tells a browser which file to take and a reader nothing about which is which. The second was simply missing: preview_html appeared nowhere in api/routers/seo.py, while the interactive version exists for 2,229 of 3,583 implementations (plotly, altair, bokeh, pygal, lets-plot and every JavaScript library), is publicly fetchable and returns text/html. Two thirds of the catalogue had an interactive artefact no machine reading the page could find.

The page now lists each asset in words — full-resolution light/dark, interactive light/dark — omitting what an implementation lacks, so a static library is never advertised as interactive. All four URLs verified 200 against the live bucket; three more tests, 1657 passing.

One thing deliberately not removed, since it came up: og:image. It is not the body image's duplicate — it is the mechanism by which a shared link shows a picture at all. Dropping it would leave social previews blank. The body image was the redundant one, and that is what this PR replaced.

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 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (3)

api/routers/seo.py:534

  • In the fallback branch (when preview_url_light is missing), the <img> alt uses title_esc/lib_name_esc, which were escaped with quote=False. If either contains a double quote, this produces invalid HTML and can allow attribute injection. Build the alt text from raw values and escape with quote=True for safe attribute output.
    if impl.preview_url_light:
        plot_img = _render_picture(impl, f"{title_esc} rendered with {lib_name_esc}")
    else:
        plot_img = f'<img src="{image_esc}" alt="{title_esc} rendered with {lib_name_esc}" width="1200" height="630" />'

api/routers/seo.py:465

  • The alt attribute is interpolated without quote-escaping. Because callers currently build alt from title_esc/lib_name_esc (escaped with quote=False), a spec or library name containing a double quote (") could break the attribute and lead to HTML injection. Escaping via html.escape would double-escape existing entities, so unescape first and then escape with quote=True.
        f"<picture>{source}"
        f'<img src="{light_default}" srcset="{html.escape(_sized_srcset(impl.preview_url_light), quote=True)}"'
        f' alt="{alt}" />'
        f"</picture>"

docs/reference/seo.md:277

  • This section hard-codes the current count of implementations with interactive HTML ("2,229 of 3,583"). That number will drift as the catalog grows, which can make this reference doc inaccurate over time. Consider describing this as a large subset (and keep the list of libraries) without exact counts, or link to a statistic that is generated from the database.
The interactive HTML exists for **2,229 of 3,583 implementations** (plotly,
altair, bokeh, pygal, lets-plot and every JavaScript library), is publicly
fetchable, and was mentioned nowhere in the machine-facing page until this list
existed. A static library simply gets no such entry.

From the Copilot review, which is right about the gap and wrong about the
fix. It suggested escaping alt inside the helper; the callers already
escape, and html.escape defaults to quote=True, so doing it again turns a
quoted spec title into a visible &amp;quot; in the alt text.

The real gap is that the contract was implicit. It is now stated, matching
the one _render_bot_html already carries, and a test drives the real
builder with the title 'Bar "Chart" & <b>' to prove the caller honours it
and that nothing is double-escaped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MarkusNeusinger

Copy link
Copy Markdown
Owner Author

Right about the gap, wrong about the fix — so I have done the first half.

Escaping inside the helper would double-escape: html.escape defaults to quote=True, and both callers already escape, so a spec title containing a quote would render as a visible &amp;quot; in the alt text. The suggestion to "unescape first" would make that worse rather than safer.

html.escape('Bar "Chart"')            → Bar &quot;Chart&quot;      attribute-safe
html.escape(html.escape(...))         → Bar &amp;quot;Chart&amp;quot;  visible noise

What was genuinely missing is that the contract was implicit. It is now stated on _render_picture, matching the one _render_bot_html already carries, and a test drives the real builder — not the helper in isolation, since the question is whether the caller honours the contract — with the title Bar "Chart" & <b>, asserting both that the attribute survives and that nothing is double-escaped.

Copilot AI review requested due to automatic review settings August 18, 2026 21:25

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 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (2)

docs/reference/seo.md:255

  • The new section explains that implementation pages now embed the plot render as a <picture>, but the “Per-page body content” table above still says the implementation page body contains a preview <img>. This makes the reference doc internally inconsistent; update the table row to reflect the new <picture> + asset list behavior.
## What a crawler sees of the plot

Two different images exist per implementation, and the bot page carries both,
deliberately:

docs/reference/seo.md:277

  • This reference doc hard-codes an exact count of implementations with interactive HTML ("2,229 of 3,583"). Because the catalog size changes over time, this will quickly become inaccurate; prefer a stable phrasing (for example “roughly two thirds”) or qualify the number with a date/source.
The interactive HTML exists for **2,229 of 3,583 implementations** (plotly,
altair, bokeh, pygal, lets-plot and every JavaScript library), is publicly
fetchable, and was mentioned nowhere in the machine-facing page until this list
existed. A static library simply gets no such entry.

@MarkusNeusinger
MarkusNeusinger enabled auto-merge (squash) August 18, 2026 21:30
@MarkusNeusinger
MarkusNeusinger merged commit 1b7dcf9 into main Aug 18, 2026
8 checks passed
@MarkusNeusinger
MarkusNeusinger deleted the feat/seo-expose-the-actual-render branch August 18, 2026 21:32
MarkusNeusinger added a commit that referenced this pull request Aug 18, 2026
Merging main brought the five entries that landed after 3.1.0 was cut
(#10476, #10477, #10479, #10482, #10483) into the version section, where
they belong -- the image work and the sitemap fix are part of this
release. Dates the release to the day it is published.
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.

2 participants