diff --git a/CHANGELOG.md b/CHANGELOG.md index c0ee24cde9..65f9971dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,20 @@ 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`. 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 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..eb082e209c 100644 --- a/api/routers/seo.py +++ b/api/routers/seo.py @@ -433,6 +433,69 @@ 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. + + ``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 + ``"``. ``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") + 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"{_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}. @@ -461,10 +524,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..8fc00805f9 100644 --- a/docs/reference/seo.md +++ b/docs/reference/seo.md @@ -248,6 +248,42 @@ 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. + +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** +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..e8ca241372 100644 --- a/tests/unit/api/test_seo_helpers.py +++ b/tests/unit/api/test_seo_helpers.py @@ -19,7 +19,10 @@ _jsonld_script, _lastmod, _meta_description, + _render_asset_list, _render_bot_html, + _render_picture, + _sized_srcset, _spec_index_entries, _spec_links_html, ) @@ -488,3 +491,100 @@ 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") + + +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_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)) + assert "Interactive version" not in out + assert "Full-resolution render, light theme" in out