From 346ebb00287b2e3805f9db304a9a225aa1568c21 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:02:19 +0200 Subject: [PATCH 1/2] fix(seo): declare site icons for crawlers and ship favicon.ico Crawlers receive the bot HTML from api/routers/seo.py, whose head had no icon link, and the /favicon.ico fallback returned 404, so Google showed no icon for anyplot.ai. The bot template now mirrors the icon links of app/index.html, and app/public/ ships favicon.ico, apple-touch-icon.png and a square icon-512.png for Organization.logo. The favicon itself is now outlined from MonoLisa Bold by scripts/generate_favicon.py: an SVG favicon loads no webfonts, so the former mark rendered in the system monospace. Co-Authored-By: Claude Fable 5.1 --- api/routers/seo.py | 10 ++- app/index.html | 7 +- app/public/apple-touch-icon.png | Bin 0 -> 2557 bytes app/public/favicon.ico | Bin 0 -> 2373 bytes app/public/favicon.svg | 9 +-- app/public/icon-512.png | Bin 0 -> 7727 bytes changelog.d/site-icons.md | 17 ++++ docs/reference/seo.md | 21 +++++ docs/reference/style-guide.md | 2 +- scripts/generate_favicon.py | 121 +++++++++++++++++++++++++++++ tests/unit/api/test_seo_helpers.py | 7 ++ 11 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 app/public/apple-touch-icon.png create mode 100644 app/public/favicon.ico create mode 100644 app/public/icon-512.png create mode 100644 changelog.d/site-icons.md create mode 100644 scripts/generate_favicon.py 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 0000000000000000000000000000000000000000..94f5ff3db7f8db8b8852b195300014efdeed917d GIT binary patch literal 2557 zcmcIm`#%#77ZxJ7D9SD58%j#K%(pP+7K*vdCghfwOKugC=6+pMn9DnN8|IRf zg)p%ZMy?4Xqb--s+xIVc`~L9$aGvvdKIi$I=bWF;NwNi-iwMdHa&d8qSX!9c|IUs7 z#1X#V2pr(kAU%N-CC9OJvm*WGt+1qak9O$ zu67kAANyVknv9UXOKWedWk{lawDdo!pu z2C&TDw@KpFof~q0>*~%$Ad&7PZIs3n`vd~Pef2x`i$E`!ytL$dRwV(vLhQyO4D)uS z2hj(lx=zmQN)&5y%~(HKeJP9bbiJ!@r9+ZwHw^@?fmh+b?!e#Co`GUelnZKVq74(J zS!0^ekzQF@;C#=}t57yOsv1G#i{R`+S<~ZrCY^U@sp-U)+T}hBBe#L zaT~s49ZF0fyMJXeiuU0MMtVVE^Rj)=xEU^psI37-P*^l z@_ElbJt!QDRvn*;WKPV`VruipUPEDzb~#!(HA;|^$YH-QD_s1n?3r7nm+hx5=dRQr7?wUY<)-_1Z1PBE$P2?2OlBv3%Br8>oq()O*b(-| zn}Ut3(e=0H5;QfkP3A9a^A!^aXAic>=s;Kn{-ErxSy2ZW+$er~x_-XsHdOPV8 zi)YTKq^92EXZnQs?hFFMJoeqTZ!V+?g}`R?*0+B=I9XC8rtl3%A)^heh(3x{c2ueY zQA`Gsem-kt;eF{EeY(QLq-+8zxx^?Uswb>VrBUS-cCL3IQjavoA$9dMWX=T_8eKIX zX8pSDqZpH$ht3Nw0+V*gmZKl+dNi_1u~^c;z~Cu8r%JKvPXh~G2ltqsJ%kH)E~?pZ zM?I?>Mn%4~g%Uzjt-WpVG>R`r6rh__!RzAD5yrxgZI(a(830&#o3s9?^&?zIFCBT# zLC+>WmP6p6h#E0ChR#aj=9aVgQs!2vPPm})J2!G?Q9;o|MQsN_*#bB2u}^|)(V5ns zzh+cSvW_jq^}*-?FoZeDlu#6$D)kNdQnTBQ++9+`<^xlbK%EPdYu;x5LCc3~*K{I3 z#?z)_A)#l;mqe6CA;YPa5hsk7oU$nddOQdYGf|A4K25f;g7EmwQCLPUm#}M9j|l`l z{%Jvov^Xjq{kUU+{y0ag*oxOBU=@wHvUtcBj6o`j=4&8zWu(1u zL{ckn)U8C4zCxb4!}$z(vNO4817+P)Z^C4 ze8~U{)m~85Wo?|fuQeubv_S&SFbRrPr{<1ZgeZ!aT&w`Cdf1FGd_1*bFj(QwjdX$S z!N1fg3C`V$DcsX+zWc0=pP!ZQU<{+rQ{!Ir^0t0Dd<+m&of}+Uja5;f5;3&o-DOv8 zb@|;*42#^KLS-0xxNd{00deH$%G3TIue0Hq;g1YqjOo>d4Hd}v5X(k4=LxOY-apN$FrB}>6!W; z%;Pb}l4{S*WPfE|EcvF&T-))EJzC-1cdEJM@>t6(_{;Wfcjo3EQ$Ii`kO(nSdkq$w_Yf}MycM8zP;M|;SabPJLxCusMpgXxJ5bF(P(entCPA_PJpJ9-yOxT)wr8sy@OpoH2EE%w}RQi%1PQE zV;5h}GP<@B^!axwpw$zg*E2+Fr_3}*Q%aR0Fn|%IhZozm@VnjnEJ1vlQ%akf)TQ5D#Lz+#^$e{sw#pKexMm~3EvOaa)cM1>$X+l*>jBqC2kykJ2JsF2QhzO z$XRYm;x=ds`P6O}vLE`y#lvyyf?02y(=eqPyV&Yo!2{4@B6Ss{ex+_53l2cF8snmb zAtDV*Kg(=w`fsHCtp-%pYj}^{!l6W05DYr_IZeSmp*8v5@u(qWn87EOhLv+rO-Iwc zuQ4|_z7d)Me8Q^%M81#3-1l7HP~75W#yzD%&%j+j0K|dtc~K literal 0 HcmV?d00001 diff --git a/app/public/favicon.ico b/app/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8c7f1c9a26cedb3e1599479af93608e44ddd6147 GIT binary patch literal 2373 zcmah}c{J4R7ypiB$TVXwJ7XO}_GKzD#=b-^Wh7(lgfdY=FA>>Nrl_%mVeDJlY{{B8 zvNhHijO@Ft1qcANerXcF zZ6;;_m|^%-RDRo7766!Iym%XH6DWre2g3_BGeuuy{J$!2v4a^>eCr4Y0H9zqw1Hhn zW)|U`g_JE{Y&P#oizurt?zp#nql!zbCAj8ih8eBUdqce9#zx|PB{ z?8#?~(C)(D>c>o+ z1UrK7`#2kC$VV;~5D>AuH(s^~hCXqsI8b^u57LRAQH(#}>=D0~lr0L}&_QC7~&{=UxAn{~E0_*2ttD@97ZJm>nM9b9nkFPfcVg}r zP|oi+C1t0-cyD7FT@Gj(yPtBzJ@PX$^F~(YrEpBHP3SGty46~;sOQv16*Fk|7Gh(t zzp5?0>Bq+U**i=ggeTl>(?%YTELcP&IHu(JAOmi*F(QmR(R>f3n&2xKksJA-k^Ao_ zyg|WrpvTh3CKuusFBDiSZaG&d_75%=lv27se}3(AdEmK6!zwo*t&w#jB-(u`pA==M z;6ao!Bh>2gM%Xr#M@~8Yl?lQGh0m4VUbM9nvtpr} z>SJF^; zGolEN5w@mTJn?M_%f7LRgX5nDS{X)^px3VUjr}-6F|VDb=b1tLl`HKB-07TY(0MYG zwAnP3E<7}(-KQanNn@1j8y>z=L-8#uvmlR*J9sSH^BKjb&W;+)yz*%sx+3%OZHQ6|@nWD{`fbc#|@?? zIaH@(CHiK3ci8*!92<}_Hwi(y3wHVcFyPllQ~8$xmzjP%000)*|1co^yr0lzk&X}R zEA1ugOUD&KWK4-zMM?FDWs0eJet?XM7A(kLSd)4K)A~W4;N}I@`s7Xbvxl;jQbZwQ zj&XJNPHAZTVa^gurC}ug#*Igb%coM$^FG>JKbZYjiXrz`E8~)vLX-rRf{=w`RclgA`^x4MXH{}Is<#i9mQsIC)#u)`o*#&I-eZM3 zyfPFyokZhgx6o#jWpN2?V+P<3hLr~-itY|@mpOm9`*hSk=)McI?0_9GE!>ht3B35K z)XBj#Vs%etH;alY`{E{zqWgWo-GaP?l|nAnUo_7x(X=t2#DK|s#isk_6*CU0ZPu@W!e(wsjUSw0pFX_vk@(VvJ!~y~oUVqc+}R+9L@wo`wuzQyFm- zL0S)T7M_n#SerQL3{o*8JU$na5d7{eMPEVCr2JAVuEIqpxofsP6|e|veomk~Ef%fTttoe#J)RUdM!&Lga6X!{*j9Ii(VGdGV>+Ji>#7!z)9+2{ z_ikCHW+Kx_ML^`nd!_N+$HmfHk+io&T(v+?e zce9riYYe^hi=7OL?TjtLSj~%Z#?_r6;A>jg^j(@3*Msu}AC!gJ0xaB8# zt;R!;PG=g>fYycw{uwhLSqw6=4W6b}C&`7Ksp}|5l=y=!Prf7#1aiJ%WpZU3{$=xDoKBJmU>#U7QXFBPcdgzI1 zc*r~?|BqvoTLo}F4zTA(5+Wfat=sao>KU;@!e()B0LFObK@m{{$n@F1qZOUi5oR+W zG(b-m$nQ4|fe@q(wgMJjM2;#8ae$}r&PJZ+6ED`PiKvTAsEv~g198{gzri)cTrcSk ze|s(JBvh3VqwSI&qgScd(OVgzT~<%4i50(##anH!Oilh+_gmWol|Fxu!FbRN7rsPN z841D^#h|^Pay7IXPlvtcu=VWvoI99}55q@naA{zrEr5FnC&wgEJf_sDmNdY6uPTX( zER*C)w6>;XlXhmeclO}z$}zP5b2f===coE^BW6c+Rv#DYx+m`nW$-IU Kwcq;h_3{ruR!3z3 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d678dcc2fef9e02d48c329bb2a71d7b218d881ea GIT binary patch literal 7727 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 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. + +### 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. 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 From d5c36fa22c5fa8e3fa2d8eeb391514efa4cb094f Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:13:54 +0200 Subject: [PATCH 2/2] docs(changelog): reference #11838 in the site-icons fragment Co-Authored-By: Claude Fable 5.1 --- changelog.d/site-icons.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/site-icons.md b/changelog.d/site-icons.md index d1718d88868..f6fedef578f 100644 --- a/changelog.d/site-icons.md +++ b/changelog.d/site-icons.md @@ -5,7 +5,7 @@ `/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. + the new square `icon-512.png` instead of the 1200×630 banner. (#11838) ### Changed @@ -14,4 +14,4 @@ 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. + PNG siblings from that one SVG. (#11838)