Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<picture>` 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 `<picture>` 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
Expand Down
81 changes: 80 additions & 1 deletion api/routers/seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
``&amp;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")
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'<source srcset="{dark_set}" media="(prefers-color-scheme: dark)" />'
# 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"<picture>{source}"
f'<img src="{light_default}" srcset="{html.escape(_sized_srcset(impl.preview_url_light), quote=True)}"'
f' alt="{alt}" />'
f"</picture>"
f"{_render_asset_list(impl)}"
)
Comment thread
MarkusNeusinger marked this conversation as resolved.


def _render_asset_list(impl) -> str:
"""Name every asset this implementation has, and say which is which.

The <picture> 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'<li><a href="{html.escape(url, quote=True)}">{label}</a></li>')
return f"<h2>Renders</h2><ul>{''.join(items)}</ul>" 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}.

Expand Down Expand Up @@ -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)})</a></li>"
)

# 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'<img src="{image_esc}" alt="{title_esc} rendered with {lib_name_esc}" width="1200" height="630" />'

body = (
f"<h1>{title_esc} — {lib_name_esc}</h1>"
f"<p>{desc_esc}</p>"
f'<img src="{image_esc}" alt="{title_esc} rendered with {lib_name_esc}" width="1200" height="630" />'
f"{plot_img}"
+ (
f"<h2>{html.escape(lang_name)} source ({lib_name_esc})</h2><pre><code>{html.escape(code)}</code></pre>"
if code
Expand Down
36 changes: 36 additions & 0 deletions docs/reference/seo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<picture>` |

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 `<picture>`
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.
Expand Down
100 changes: 100 additions & 0 deletions tests/unit/api/test_seo_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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 "<source" not in _render_picture(self._impl(dark=False), "alt")

def test_the_full_resolution_stays_reachable(self) -> None:
"""Left out of the srcset, so it needs its own way in."""
assert f'<a href="{self.BASE}/plot-light.png">' in _render_picture(self._impl(), "alt")


class TestRenderAssetList:
"""A <picture> 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'<a href="{self.BASE}/plot-light.png">Full-resolution render, light theme</a>' in out
assert f'<a href="{self.BASE}/plot-dark.png">Full-resolution render, dark theme</a>' 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'<a href="{self.BASE}/plot-light.html">Interactive version, light theme</a>' in out
assert f'<a href="{self.BASE}/plot-dark.html">Interactive version, dark theme</a>' 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" & <b>'
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 &quot;Chart&quot; &amp; &lt;b&gt; rendered with Altair"' in out
# and not double-escaped into visible noise
assert "&amp;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
Loading