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
10 changes: 9 additions & 1 deletion api/routers/seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,18 @@ async def _refresh_sitemap() -> str:
# HTML template for search/social crawlers. Meta tags drive social previews;
# the {body} slot carries what search engines index (headings, code, links,
# JSON-LD) — an empty body reads as a thin page and wastes the crawl.
#
# The icon links are absolute and mirror app/index.html: a crawler never
# receives index.html, so without them Google's only candidate was the
# /favicon.ico fallback. The files live in app/public/ and are served by the
# app's nginx, whichever host (anyplot.ai, python.anyplot.ai) proxied the bot.
BOT_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="https://anyplot.ai/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://anyplot.ai/favicon.svg" />
<link rel="apple-touch-icon" href="https://anyplot.ai/apple-touch-icon.png" />
Comment thread
MarkusNeusinger marked this conversation as resolved.
<title>{title}</title>
<meta name="description" content="{description}" />
<meta name="robots" content="{robots_content}" />
Expand Down Expand Up @@ -212,7 +220,7 @@ async def _refresh_sitemap() -> str:
"@type": "Organization",
"name": "anyplot",
"url": "https://anyplot.ai",
"logo": "https://anyplot.ai/og-image.png",
"logo": "https://anyplot.ai/icon-512.png",
"description": (
f"Open plot catalogue with AI-generated implementations across {_LIBRARY_COUNT} "
f"libraries in {_LANGUAGE_LIST}."
Expand Down
7 changes: 6 additions & 1 deletion app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- Site icons, generated by scripts/generate_favicon.py. The .ico is what
crawlers and SVG-less clients fall back to; keep it listed first. The
bot HTML in api/routers/seo.py carries the same three links. -->
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="the open plot catalogue. every plot begins as a library-agnostic spec; ai drafts implementations across 15 libraries in python, r, julia &amp; javascript. browse, copy, adapt — colorblind-safe by default." />
<meta name="keywords" content="python, r, julia, javascript, plotting, matplotlib, seaborn, plotly, bokeh, altair, plotnine, pygal, letsplot, ggplot2, makie, chart.js, d3, echarts, highcharts, mui x charts, data visualization, charts, graphs, ai-generated, code examples, colorblind-safe, imprint palette" />
Expand Down Expand Up @@ -130,7 +135,7 @@
"@type": "Organization",
"name": "anyplot",
"url": "https://anyplot.ai",
"logo": "https://anyplot.ai/og-image.png",
"logo": "https://anyplot.ai/icon-512.png",
"description": "Open plot catalogue with AI-generated implementations across 15 libraries in Python, R, Julia, and JavaScript.",
"sameAs": [
"https://github.com/MarkusNeusinger/anyplot",
Expand Down
Binary file added app/public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/public/favicon.ico
Binary file not shown.
9 changes: 3 additions & 6 deletions app/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/public/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions changelog.d/site-icons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
### Fixed

- **Google showed no icon for anyplot.ai.** Crawlers are routed to the bot HTML
from `api/routers/seo.py`, whose `<head>` declared no icon, and Google's
`/favicon.ico` fallback returned 404. The bot template now carries the same
icon links as `app/index.html`, and `app/public/` ships a real `favicon.ico`
(16, 32, 48 px) and an `apple-touch-icon.png`. `Organization.logo` points at
the new square `icon-512.png` instead of the 1200×630 banner. (#11838)

### Changed

- **The favicon is drawn from the real MonoLisa outlines.** An SVG favicon
loads no webfonts, so the `<text>` mark always rendered in the viewer's
system monospace. `scripts/generate_favicon.py` outlines the `ap` monogram
from MonoLisa Bold into plain paths on an opaque paper ground (a transparent
icon with dark ink vanished on dark result pages) and rasterizes the ICO and
PNG siblings from that one SVG. (#11838)
21 changes: 21 additions & 0 deletions docs/reference/seo.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ landing deep can walk the site without executing the SPA.
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="https://anyplot.ai/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://anyplot.ai/favicon.svg" />
<link rel="apple-touch-icon" href="https://anyplot.ai/apple-touch-icon.png" />
<title>{title}</title>
<meta name="description" content="{description}" />
<meta property="og:title" content="{title}" />
Expand All @@ -259,6 +262,24 @@ landing deep can walk the site without executing the SPA.
</html>
```

### Site icons

The three icon links in the template mirror `app/index.html`. A crawler never receives
`index.html`, so the bot `<head>` has to declare the icons itself; without them Google's only
candidate is the `/favicon.ico` fallback. The files live in `app/public/` and are generated by
`scripts/generate_favicon.py`:

| File | Purpose |
|------|---------|
| `favicon.ico` (16, 32, 48 px) | Google's favicon fallback and clients without SVG icon support |
| `favicon.svg` | Browser tab. Outlined paths, because an SVG favicon loads no webfonts |
| `apple-touch-icon.png` (180 px) | iOS home screen and link previews |
| `icon-512.png` | Square `Organization.logo` in the JSON-LD of `index.html` and `seo.py` |

The links are absolute (`https://anyplot.ai/...`) so they resolve the same way whichever host
proxied the bot. After an icon change, request re-indexing of the home page in Search Console;
Google refreshes favicons over days to weeks.

Per-page body content:

| Page | Body | JSON-LD |
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/style-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ font-weight: 700 (bold) for letters, 400 (regular) for parens

| Context | Size | Notes |
|-------------------|-----------|---------------------------------|
| Favicon / app icon| 16–64px | Reduce to `a.p` — keeps the dot |
| Favicon / app icon| 16–64px | Reduce to `ap` over the green square — the dot, moved below the `a` so both letters keep their full size. Outlined paths, generated by `scripts/generate_favicon.py` |
| Top-nav | 18–22px | Standard use |
| Hero section | 40–64px | Occasional, paired with headline|
| Large display | 80–96px+ | Landing hero, posters |
Expand Down
121 changes: 121 additions & 0 deletions scripts/generate_favicon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Generate the site icons in `app/public/` from the real MonoLisa outlines.

The icon is the `ap` monogram over the brand-green square on the paper ground.
It is written as plain `<path>` outlines: an SVG used as a favicon is rendered
in an isolated context that loads no webfonts, so `<text>` in MonoLisa always
fell back to whatever monospace face the viewer's system had. The raster
siblings exist because crawlers and older clients never read the SVG —
Google's fallback is `/favicon.ico`, iOS wants `/apple-touch-icon.png`, and
the schema.org `Organization.logo` needs a square image (`/icon-512.png`).

Usage:
uv run --with resvg-py python scripts/generate_favicon.py

The outputs are committed; rerun only when the mark or the palette changes.
MonoLisa is fetched through `core.images` (GCS, cached in `/tmp/anyplot-fonts`)
and only its outlines for the two letters end up in the repository.
Comment thread
MarkusNeusinger marked this conversation as resolved.
"""

from __future__ import annotations

from io import BytesIO
from pathlib import Path

import resvg_py
from fontTools.pens.boundsPen import BoundsPen
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.transformPen import TransformPen
from fontTools.ttLib import TTFont
from PIL import Image

from core.images import _get_monolisa_font_path


PUBLIC_DIR = Path(__file__).resolve().parent.parent / "app" / "public"

PAPER = "#F5F3EC" # --bg-page
INK = "#1A1A17" # --ink
GREEN = "#009E73" # --imprint-green

VIEWBOX = 32
TEXT = "ap"
WEIGHT = 700
FONT_SIZE = 19.0
BASELINE = 16.5
LETTER_SPACING = -0.4
SQUARE_TOP = 20.0
SQUARE_SIZE = 5.0
CORNER_RADIUS = 6

ICO_SIZES = [(16, 16), (32, 32), (48, 48)]
PNG_OUTPUTS = {"apple-touch-icon.png": 180, "icon-512.png": 512}


def _format(value: float) -> str:
return f"{value:.2f}".rstrip("0").rstrip(".")


def build_svg(font_path: Path, corner_radius: int = CORNER_RADIUS) -> str:
"""Outline the monogram at the icon's geometry and return the SVG document."""
font = TTFont(font_path)
glyphs = font.getGlyphSet(location={"wght": WEIGHT})
cmap = font.getBestCmap()
scale = FONT_SIZE / font["head"].unitsPerEm

names = [cmap[ord(char)] for char in TEXT]
width = sum(glyphs[name].width * scale for name in names) + LETTER_SPACING * (len(names) - 1)
x = VIEWBOX / 2 - width / 2

commands = []
ink_left = None
for name in names:
transform = (scale, 0, 0, -scale, x, BASELINE)
pen = SVGPathPen(glyphs, ntos=_format)
glyphs[name].draw(TransformPen(pen, transform))
commands.append(pen.getCommands())
if ink_left is None:
bounds = BoundsPen(glyphs)
glyphs[name].draw(TransformPen(bounds, transform))
ink_left = bounds.bounds[0]
x += glyphs[name].width * scale + LETTER_SPACING

return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {VIEWBOX} {VIEWBOX}">\n'
f' <rect width="{VIEWBOX}" height="{VIEWBOX}" rx="{corner_radius}" fill="{PAPER}"/>\n'
f' <path fill="{INK}" d="{" ".join(commands)}"/>\n'
f' <rect x="{_format(ink_left)}" y="{_format(SQUARE_TOP)}" width="{_format(SQUARE_SIZE)}" '
f'height="{_format(SQUARE_SIZE)}" fill="{GREEN}"/>\n'
"</svg>\n"
)


def render_png(svg: str, size: int) -> bytes:
return bytes(resvg_py.svg_to_bytes(svg_string=svg, width=size, height=size))


def main() -> None:
font_path = _get_monolisa_font_path()
if font_path is None:
raise SystemExit("MonoLisa is unavailable (GCS access needed); refusing to outline a fallback face.")

svg = build_svg(font_path)
(PUBLIC_DIR / "favicon.svg").write_text(svg, encoding="utf-8")

# The large PNGs are full-bleed squares: iOS and Google apply their own mask,
# and transparent corners would come out black on a home screen.
square_svg = build_svg(font_path, corner_radius=0)
for filename, size in PNG_OUTPUTS.items():
(PUBLIC_DIR / filename).write_bytes(render_png(square_svg, size))

# Each ICO entry is rendered at its own size rather than downscaled from one
# large bitmap, so the 16 px entry keeps the hinting resvg gives it.
frames = [Image.open(BytesIO(render_png(svg, width))) for width, _ in ICO_SIZES]
frames[-1].save(PUBLIC_DIR / "favicon.ico", format="ICO", sizes=ICO_SIZES, append_images=frames[:-1])

for filename in ("favicon.svg", "favicon.ico", *PNG_OUTPUTS):
print(f"{filename}: {(PUBLIC_DIR / filename).stat().st_size} B")


if __name__ == "__main__":
main()
7 changes: 7 additions & 0 deletions tests/unit/api/test_seo_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,13 @@ def test_has_canonical(self) -> None:
assert 'rel="canonical"' in result
assert url in result

def test_has_site_icon_links(self) -> None:
"""Crawlers never receive index.html, so the bot head declares the icons itself."""
result = _render_bot_html(title="t", description="d", image="i", url="u")
assert '<link rel="icon" href="https://anyplot.ai/favicon.ico" sizes="48x48" />' in result
assert '<link rel="icon" type="image/svg+xml" href="https://anyplot.ai/favicon.svg" />' in result
assert '<link rel="apple-touch-icon" href="https://anyplot.ai/apple-touch-icon.png" />' in result

def test_default_body_and_nav(self) -> None:
result = _render_bot_html(title="t", description="d", image="i", url="u")
assert "<h1>t</h1><p>d</p>" in result
Expand Down
Loading