From e457dc6ddc7a08bba0a02b6c79a32f734f708bba Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:11:38 +0200 Subject: [PATCH 1/3] feat(seo): show crawlers the plot, not the card it sits inside MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- CHANGELOG.md | 11 +++++++ api/routers/seo.py | 53 +++++++++++++++++++++++++++++- docs/reference/seo.md | 24 ++++++++++++++ tests/unit/api/test_seo_helpers.py | 43 ++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0ee24cde9..d0412ad490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,17 @@ aggregate instead: an italic *Catalog* line at the end of the version section an ### Changed +- **Implementation pages now show the plot, not the card it sits inside** — the bot page's only + image was the 1200×630 `og:image`, in which the render is a thumbnail surrounded by branding + chrome with its cell labels barely legible. Right for a shared link, useless to an assistant asked + to show the plot. The body now carries the actual render as a `` with both themes, using + the `_400`/`_800`/`_1200` derivatives the pipeline already writes beside every plot, whose suffix + is the true pixel width. The full-size original stays out of the `srcset` — its width varies per + plot (2400, 3200, 4766 among those measured), so it has no honest `w` descriptor — and gets its + own link; `src` points at the 1200px variant so a consumer ignoring `srcset` does not pull a + five-megapixel file. `og:image` is unchanged, and attribution is not lost either way: every + render's title reads `{spec} · {language} · {library} · anyplot.ai`. + - **`og_image_view` is split by who fetched the image** — a social or messenger preview means a human shared a link, which is a product signal and stays on the main site; a search or AI crawler fetching the same image is not a share and now goes to `bots.anyplot.ai`. The distinction matters diff --git a/api/routers/seo.py b/api/routers/seo.py index 189121d95d..8db33dc569 100644 --- a/api/routers/seo.py +++ b/api/routers/seo.py @@ -433,6 +433,41 @@ def _build_spec_hub_html(spec, image: str) -> str: ) +def _sized_srcset(full_url: str) -> str: + """Offer the pipeline's width derivatives for a full-size render URL. + + `plot-light.png` is written alongside `plot-light_400.png`, `_800` and + `_1200`, and the suffix is the actual pixel width. The original is left out: + its width differs per plot, so it has no honest `w` descriptor. + """ + stem = full_url[:-4] if full_url.endswith(".png") else full_url + return ", ".join(f"{stem}_{w}.png {w}w" for w in (400, 800, 1200)) + + +def _render_picture(impl, alt: str) -> str: + """The actual plot render, both themes, at a size a consumer can choose.""" + light = html.escape(impl.preview_url_light, quote=True) + light_default = html.escape( + impl.preview_url_light[:-4] + "_1200.png" + if impl.preview_url_light.endswith(".png") + else impl.preview_url_light, + quote=True, + ) + source = "" + if impl.preview_url_dark: + dark_set = html.escape(_sized_srcset(impl.preview_url_dark), quote=True) + source = f'' + # src is the 1200px variant so a naive consumer does not pull a 4766px file; + # the full original is reachable through the link the body adds beside this. + return ( + f"{source}" + f'{alt}' + f"" + f'

Full-resolution render

' + ) + + def _build_impl_html(spec, impl, code: str | None, image: str) -> str: """Full bot page for an implementation detail /{spec_id}/{language}/{library}. @@ -461,10 +496,26 @@ def _build_impl_html(spec, impl, code: str | None, image: str) -> str: f"{title_esc} in {html.escape(sib_lib_name)} ({html.escape(sib_lang_name)})" ) + # og:image stays the 1200x630 social card — it is what a shared link needs. + # The body carries the actual render, because in that card the plot is a + # small thumbnail inside branding chrome: fine for a link preview, useless + # to an assistant asked to show the plot. Attribution is not lost, the plot + # title itself reads "{spec} · {language} · {library} · anyplot.ai". + # + # The pipeline already writes _400/_800/_1200 derivatives beside every + # render, and their suffix is the true pixel width (verified across square + # and wide plots in four languages). The full-size original is NOT in the + # srcset: its width varies per plot — 2400, 3200, 4766 — so no honest `w` + # descriptor exists for it. It is linked separately instead. + if impl.preview_url_light: + plot_img = _render_picture(impl, f"{title_esc} rendered with {lib_name_esc}") + else: + plot_img = f'{title_esc} rendered with {lib_name_esc}' + body = ( f"

{title_esc} — {lib_name_esc}

" f"

{desc_esc}

" - f'{title_esc} rendered with {lib_name_esc}' + f"{plot_img}" + ( f"

{html.escape(lang_name)} source ({lib_name_esc})

{html.escape(code)}
" if code diff --git a/docs/reference/seo.md b/docs/reference/seo.md index 6406e1e120..42a1f85033 100644 --- a/docs/reference/seo.md +++ b/docs/reference/seo.md @@ -248,6 +248,30 @@ Display names (Matplotlib, Makie.jl, Apache ECharts, …) are derived from `core/constants.py` (`LANGUAGES_METADATA` / `LIBRARIES_METADATA`) — never hand-maintained in the router. +## What a crawler sees of the plot + +Two different images exist per implementation, and the bot page carries both, +deliberately: + +| Image | What it is | Where it appears | +|---|---|---| +| `api.anyplot.ai/og/{spec}/{language}/{library}.png` | 1200×630 branded social card; the plot is a thumbnail inside chrome | `og:image`, `twitter:image` | +| `…/plot-light.png`, `…/plot-dark.png` in GCS | the actual render, full resolution | the page body, as a `` | + +The card is right for a shared link and wrong for an assistant asked to show the +plot — in it the plot is roughly a third of the frame and the cell labels are +barely legible. So the body carries the real render instead. Attribution does not +suffer: every render's own title reads `{spec} · {language} · {library} · +anyplot.ai`, so the source travels with the image wherever it is embedded. + +The pipeline writes `_400`, `_800` and `_1200` derivatives beside each render and +the suffix is the true pixel width — verified across square and wide plots in all +four languages. Those three form the `srcset`. The full-size original is **not** +in it: its width varies per plot (2400, 3200, 4766 among the ones measured), so +no honest `w` descriptor exists for it. It gets its own link instead, and the +`src` points at the 1200px variant so a consumer ignoring `srcset` does not pull +a five-megapixel file. + ## Branded OG images Dynamically generated preview images with anyplot.ai branding. diff --git a/tests/unit/api/test_seo_helpers.py b/tests/unit/api/test_seo_helpers.py index 0fb58bc860..f53fed86a8 100644 --- a/tests/unit/api/test_seo_helpers.py +++ b/tests/unit/api/test_seo_helpers.py @@ -20,6 +20,8 @@ _lastmod, _meta_description, _render_bot_html, + _render_picture, + _sized_srcset, _spec_index_entries, _spec_links_html, ) @@ -488,3 +490,44 @@ def test_runs_before_escaping_so_entities_stay_intact(self) -> None: escaped = html_module.escape(_meta_description(text)) # A truncated entity would leave a bare & followed by a non-entity run assert re.search(r"&(?!amp;|lt;|gt;|quot;|#x27;)", escaped) is None + + +class TestPlotRender: + """The body shows the plot itself, not the social card it sits inside.""" + + BASE = "https://storage.googleapis.com/anyplot-images/plots/box-basic/python/altair" + + def _impl(self, dark: bool = True) -> MagicMock: + impl = MagicMock() + impl.preview_url_light = f"{self.BASE}/plot-light.png" + impl.preview_url_dark = f"{self.BASE}/plot-dark.png" if dark else None + return impl + + def test_srcset_offers_the_pipeline_widths(self) -> None: + """The suffix IS the pixel width — verified against the live renders.""" + assert _sized_srcset(f"{self.BASE}/plot-light.png") == ( + f"{self.BASE}/plot-light_400.png 400w, " + f"{self.BASE}/plot-light_800.png 800w, " + f"{self.BASE}/plot-light_1200.png 1200w" + ) + + def test_the_full_size_original_is_not_in_the_srcset(self) -> None: + """Its width varies per plot (2400, 3200, 4766) — no honest `w` exists.""" + srcset = _sized_srcset(f"{self.BASE}/plot-light.png") + assert f"{self.BASE}/plot-light.png" not in srcset + + def test_default_src_is_the_middle_size(self) -> None: + """A consumer ignoring srcset should not pull a 4766px file.""" + assert f'src="{self.BASE}/plot-light_1200.png"' in _render_picture(self._impl(), "alt") + + def test_dark_variant_is_offered(self) -> None: + html_out = _render_picture(self._impl(), "alt") + assert 'media="(prefers-color-scheme: dark)"' in html_out + assert f"{self.BASE}/plot-dark_800.png 800w" in html_out + + def test_no_source_element_without_a_dark_render(self) -> None: + assert " None: + """Left out of the srcset, so it needs its own way in.""" + assert f'' in _render_picture(self._impl(), "alt") From 09f45ca2b278483082c452f8247bff737a76c12c Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:18:35 +0200 Subject: [PATCH 2/3] feat(seo): name every render asset, including the interactive one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 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) --- CHANGELOG.md | 5 ++++- api/routers/seo.py | 25 ++++++++++++++++++++-- docs/reference/seo.md | 12 +++++++++++ tests/unit/api/test_seo_helpers.py | 33 ++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0412ad490..65f9971dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -146,7 +146,10 @@ aggregate instead: an italic *Catalog* line at the end of the version section an plot (2400, 3200, 4766 among those measured), so it has no honest `w` descriptor — and gets its own link; `src` points at the 1200px variant so a consumer ignoring `srcset` does not pull a five-megapixel file. `og:image` is unchanged, and attribution is not lost either way: every - render's title reads `{spec} · {language} · {library} · anyplot.ai`. + render's title reads `{spec} · {language} · {library} · anyplot.ai`. Below it, a list names every + asset in words — a `` tells a browser which file to take but tells a reader nothing about + which is which — including the **interactive version**, which exists for 2,229 of 3,583 + implementations, is publicly fetchable, and was mentioned nowhere machine-readable before. - **`og_image_view` is split by who fetched the image** — a social or messenger preview means a human shared a link, which is a product signal and stays on the main site; a search or AI crawler diff --git a/api/routers/seo.py b/api/routers/seo.py index 8db33dc569..3bc6e27846 100644 --- a/api/routers/seo.py +++ b/api/routers/seo.py @@ -446,7 +446,6 @@ def _sized_srcset(full_url: str) -> str: def _render_picture(impl, alt: str) -> str: """The actual plot render, both themes, at a size a consumer can choose.""" - light = html.escape(impl.preview_url_light, quote=True) light_default = html.escape( impl.preview_url_light[:-4] + "_1200.png" if impl.preview_url_light.endswith(".png") @@ -464,10 +463,32 @@ def _render_picture(impl, alt: str) -> str: f'{alt}' f"" - f'

Full-resolution render

' + f"{_render_asset_list(impl)}" ) +def _render_asset_list(impl) -> str: + """Name every asset this implementation has, and say which is which. + + The above already offers both themes, but only through a media + query — an agent parsing the page cannot tell from that which file is the + dark one, or that an interactive version exists at all. Two thirds of the + catalogue has one (plotly, altair, bokeh, pygal, lets-plot and every + JavaScript library), it is publicly fetchable, and until now nothing in the + machine-facing page mentioned it. + """ + items = [] + for url, label in ( + (impl.preview_url_light, "Full-resolution render, light theme"), + (impl.preview_url_dark, "Full-resolution render, dark theme"), + (impl.preview_html_light, "Interactive version, light theme"), + (impl.preview_html_dark, "Interactive version, dark theme"), + ): + if url: + items.append(f'
  • {label}
  • ') + return f"

    Renders

      {''.join(items)}
    " if items else "" + + def _build_impl_html(spec, impl, code: str | None, image: str) -> str: """Full bot page for an implementation detail /{spec_id}/{language}/{library}. diff --git a/docs/reference/seo.md b/docs/reference/seo.md index 42a1f85033..8fc00805f9 100644 --- a/docs/reference/seo.md +++ b/docs/reference/seo.md @@ -264,6 +264,18 @@ barely legible. So the body carries the real render instead. Attribution does no suffer: every render's own title reads `{spec} · {language} · {library} · anyplot.ai`, so the source travels with the image wherever it is embedded. +Below the image the page lists every asset **in words**, because a `` +tells a browser which file to take but tells a reader nothing about which is +which — and says nothing at all about the interactive version: + +- full-resolution render, light and dark +- interactive version, light and dark, where the library produces one + +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. + The pipeline writes `_400`, `_800` and `_1200` derivatives beside each render and the suffix is the true pixel width — verified across square and wide plots in all four languages. Those three form the `srcset`. The full-size original is **not** diff --git a/tests/unit/api/test_seo_helpers.py b/tests/unit/api/test_seo_helpers.py index f53fed86a8..f7198f5c14 100644 --- a/tests/unit/api/test_seo_helpers.py +++ b/tests/unit/api/test_seo_helpers.py @@ -19,6 +19,7 @@ _jsonld_script, _lastmod, _meta_description, + _render_asset_list, _render_bot_html, _render_picture, _sized_srcset, @@ -531,3 +532,35 @@ def test_no_source_element_without_a_dark_render(self) -> None: def test_the_full_resolution_stays_reachable(self) -> None: """Left out of the srcset, so it needs its own way in.""" assert f'' in _render_picture(self._impl(), "alt") + + +class TestRenderAssetList: + """A hides which file is which; the list says it in words.""" + + BASE = "https://storage.googleapis.com/anyplot-images/plots/bar-basic/python/plotly" + + def _impl(self, interactive: bool = True) -> MagicMock: + impl = MagicMock() + impl.preview_url_light = f"{self.BASE}/plot-light.png" + impl.preview_url_dark = f"{self.BASE}/plot-dark.png" + impl.preview_html_light = f"{self.BASE}/plot-light.html" if interactive else None + impl.preview_html_dark = f"{self.BASE}/plot-dark.html" if interactive else None + return impl + + def test_names_both_themes_explicitly(self) -> None: + """A media query tells a browser which file to take, not a reader which is which.""" + out = _render_asset_list(self._impl()) + assert f'Full-resolution render, light theme' in out + assert f'Full-resolution render, dark theme' in out + + def test_exposes_the_interactive_version(self) -> None: + """Two thirds of the catalogue has one and nothing machine-facing mentioned it.""" + out = _render_asset_list(self._impl()) + assert f'Interactive version, light theme' in out + assert f'Interactive version, dark theme' in out + + def test_omits_what_an_implementation_does_not_have(self) -> None: + """A static library must not be advertised as interactive.""" + out = _render_asset_list(self._impl(interactive=False)) + assert "Interactive version" not in out + assert "Full-resolution render, light theme" in out From 5dc842c4b6c615d9d815d356548e5eca4db709e2 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:25:05 +0200 Subject: [PATCH 3/3] docs(seo): state that _render_picture takes pre-escaped alt text 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 &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" & ' to prove the caller honours it and that nothing is double-escaped. Co-Authored-By: Claude Opus 5 (1M context) --- api/routers/seo.py | 9 ++++++++- tests/unit/api/test_seo_helpers.py | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/api/routers/seo.py b/api/routers/seo.py index 3bc6e27846..eb082e209c 100644 --- a/api/routers/seo.py +++ b/api/routers/seo.py @@ -445,7 +445,14 @@ def _sized_srcset(full_url: str) -> str: def _render_picture(impl, alt: str) -> str: - """The actual plot render, both themes, at a size a consumer can choose.""" + """The actual plot render, both themes, at a size a consumer can choose. + + ``alt`` must arrive HTML-escaped — the same contract ``_render_bot_html`` + carries, and for the same reason: escaping here instead would double-escape + the callers that already do it, turning a quoted spec title into a visible + ``&quot;``. ``html.escape`` defaults to ``quote=True``, so a caller that + follows the contract is attribute-safe. + """ light_default = html.escape( impl.preview_url_light[:-4] + "_1200.png" if impl.preview_url_light.endswith(".png") diff --git a/tests/unit/api/test_seo_helpers.py b/tests/unit/api/test_seo_helpers.py index f7198f5c14..e8ca241372 100644 --- a/tests/unit/api/test_seo_helpers.py +++ b/tests/unit/api/test_seo_helpers.py @@ -559,6 +559,30 @@ def test_exposes_the_interactive_version(self) -> None: assert f'Interactive version, light theme' in out assert f'Interactive version, dark theme' in out + def test_a_quoted_spec_title_cannot_break_the_alt_attribute(self) -> None: + """Verified through the real builder, not the helper in isolation. + + The helper takes pre-escaped text by contract; what matters is whether + the caller honours it, so this drives _build_impl_html with a title that + would break the attribute if it did not. + """ + spec = MagicMock() + spec.id = "bar-basic" + spec.title = 'Bar "Chart" & ' + spec.description = "d" + library = MagicMock() + library.language = "python" + library.name = "Altair" + impl = self._impl() + impl.library = library + impl.library_id = "altair" + spec.impls = [impl] + + out = _build_impl_html(spec, impl, None, "https://api.anyplot.ai/og/x.png") + assert 'alt="Bar "Chart" & <b> rendered with Altair"' in out + # and not double-escaped into visible noise + assert "&quot;" not in out + def test_omits_what_an_implementation_does_not_have(self) -> None: """A static library must not be advertised as interactive.""" out = _render_asset_list(self._impl(interactive=False))