diff --git a/api/routers/seo.py b/api/routers/seo.py index 375a5526061..a63215024b6 100644 --- a/api/routers/seo.py +++ b/api/routers/seo.py @@ -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 = """ + + + {title} @@ -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}." diff --git a/app/index.html b/app/index.html index 19bc98db9e5..175fe23c0ea 100644 --- a/app/index.html +++ b/app/index.html @@ -2,7 +2,12 @@ + + + @@ -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", diff --git a/app/public/apple-touch-icon.png b/app/public/apple-touch-icon.png new file mode 100644 index 00000000000..94f5ff3db7f Binary files /dev/null and b/app/public/apple-touch-icon.png differ diff --git a/app/public/favicon.ico b/app/public/favicon.ico new file mode 100644 index 00000000000..8c7f1c9a26c Binary files /dev/null and b/app/public/favicon.ico differ diff --git a/app/public/favicon.svg b/app/public/favicon.svg index 3654d515d9e..94a9e218e5d 100644 --- a/app/public/favicon.svg +++ b/app/public/favicon.svg @@ -1,8 +1,5 @@ - - ap - + + + diff --git a/app/public/icon-512.png b/app/public/icon-512.png new file mode 100644 index 00000000000..d678dcc2fef Binary files /dev/null and b/app/public/icon-512.png differ diff --git a/changelog.d/site-icons.md b/changelog.d/site-icons.md new file mode 100644 index 00000000000..f6fedef578f --- /dev/null +++ b/changelog.d/site-icons.md @@ -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 `` 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 `` 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) diff --git a/docs/reference/seo.md b/docs/reference/seo.md index d1920102843..018e3c8f3ad 100644 --- a/docs/reference/seo.md +++ b/docs/reference/seo.md @@ -238,6 +238,9 @@ landing deep can walk the site without executing the SPA. + + + {title} @@ -259,6 +262,24 @@ landing deep can walk the site without executing the SPA. ``` +### Site icons + +The three icon links in the template mirror `app/index.html`. A crawler never receives +`index.html`, so the bot `` 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 | diff --git a/docs/reference/style-guide.md b/docs/reference/style-guide.md index 85f7d73569d..13f4fb1ba10 100644 --- a/docs/reference/style-guide.md +++ b/docs/reference/style-guide.md @@ -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 | diff --git a/scripts/generate_favicon.py b/scripts/generate_favicon.py new file mode 100644 index 00000000000..53334a09220 --- /dev/null +++ b/scripts/generate_favicon.py @@ -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 `` outlines: an SVG used as a favicon is rendered +in an isolated context that loads no webfonts, so `` 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. +""" + +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'\n' + f' \n' + f' \n' + f' \n' + "\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() diff --git a/tests/unit/api/test_seo_helpers.py b/tests/unit/api/test_seo_helpers.py index 138a443a8ce..e1cf8696315 100644 --- a/tests/unit/api/test_seo_helpers.py +++ b/tests/unit/api/test_seo_helpers.py @@ -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 '' in result + assert '' in result + assert '' in result + def test_default_body_and_nav(self) -> None: result = _render_bot_html(title="t", description="d", image="i", url="u") assert "

t

d

" in result