Skip to content

Commit e24a7cd

Browse files
refactor(routing): restructure URLs to /{specId}/{language}/{library} (#5289)
Move language from a top-level URL prefix (`/python/...`) to a middle segment so the spec slug becomes the canonical SEO entity: /{specId} cross-language hub /{specId}/{language} language overview /{specId}/{language}/{library} implementation detail Interactive view collapses into the detail page as an in-place toggle (`?view=interactive`). Legacy `/python/*` and `/python/interactive/*` paths fall through to NotFoundPage with no redirects. Backend: `Library.language` column (default "python"), threaded through `ImplementationResponse`, `LibraryInfo`, `PlotImage`, `TopImpl`, `RelatedSpecItem`, `PlotOfTheDayResponse`. Sitemap now emits all three URL tiers; SEO proxy and OG image routes restructured accordingly. Frontend: `specPath(specId, language?, library?)` builds the dynamic path; `RESERVED_TOP_LEVEL` blocks slug collisions. `MastheadRule` and `useAnalytics` parse path segments instead of hard-coding `/python`. `InteractivePage` removed; iframe logic merged into `SpecDetailView`. Subdomain: `python.anyplot.ai` server block added with an internal nginx rewrite for the SEO bot path (canonical points back to anyplot.ai). Human SPA path still needs hostname-aware route resolution before flipping DNS. Workflow: `spec-create.yml` rejects spec IDs that collide with reserved top-level routes. https://claude.ai/code/session_01Sd9QoGJfcNU8yEhsQixDCV --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8bce2d6 commit e24a7cd

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1175
-902
lines changed

.github/workflows/spec-create.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,17 @@ jobs:
286286
exit 1
287287
fi
288288
289+
# Reserved spec slugs collide with top-level frontend routes.
290+
# Keep this list in sync with `RESERVED_TOP_LEVEL` in app/src/utils/paths.ts.
291+
RESERVED_SLUGS=(plots specs libraries palette about legal mcp stats debug api og sitemap.xml robots.txt)
292+
for reserved in "${RESERVED_SLUGS[@]}"; do
293+
if [[ "$SPEC_ID" == "$reserved" ]]; then
294+
echo "::error::Spec ID '$SPEC_ID' collides with reserved top-level route. Choose a different slug."
295+
gh issue comment ${{ github.event.issue.number }} --body "**Error:** Generated spec ID \`${SPEC_ID}\` collides with a reserved top-level route. Please rename the issue with a more specific title and re-trigger."
296+
exit 1
297+
fi
298+
done
299+
289300
echo "specification_id=$SPEC_ID" >> $GITHUB_OUTPUT
290301
echo "branch=specification/$SPEC_ID" >> $GITHUB_OUTPUT
291302
env:
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""add_language_to_libraries
2+
3+
Revision ID: e1f3a2c4d5b6
4+
Revises: b833d85c09ed
5+
Create Date: 2026-04-20 12:00:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
13+
from alembic import op
14+
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = "e1f3a2c4d5b6"
18+
down_revision: Union[str, None] = "b833d85c09ed"
19+
branch_labels: Union[str, Sequence[str], None] = None
20+
depends_on: Union[str, Sequence[str], None] = None
21+
22+
23+
def upgrade() -> None:
24+
op.add_column("libraries", sa.Column("language", sa.String(length=50), nullable=False, server_default="python"))
25+
26+
27+
def downgrade() -> None:
28+
op.drop_column("libraries", "language")

api/analytics.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ def track_og_image(
139139
request: Request,
140140
page: str,
141141
spec: str | None = None,
142+
language: str | None = None,
142143
library: str | None = None,
143144
filters: dict[str, str] | None = None,
144145
) -> None:
@@ -150,29 +151,34 @@ def track_og_image(
150151
request: FastAPI request for headers
151152
page: Page type ('home', 'plots', 'spec_overview', 'spec_detail')
152153
spec: Spec ID (optional)
154+
language: Language slug (optional) — e.g. "python"
153155
library: Library ID (optional)
154156
filters: Query params for filtered home page (e.g., {'lib': 'plotly', 'dom': 'statistics'})
155157
"""
156158
user_agent = request.headers.get("user-agent", "")
157159
client_ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "")
158160
platform = detect_platform(user_agent)
159161

160-
# Build URL based on page type
162+
# Build URL based on page type. Spec routes follow /{spec}/{language}/{library}.
161163
if page == "home":
162164
url = "https://anyplot.ai/"
163165
elif page == "plots":
164166
url = "https://anyplot.ai/plots"
165-
elif spec is not None and library:
166-
url = f"https://anyplot.ai/python/{spec}/{library}"
167+
elif spec is not None and language and library:
168+
url = f"https://anyplot.ai/{spec}/{language}/{library}"
169+
elif spec is not None and language:
170+
url = f"https://anyplot.ai/{spec}/{language}"
167171
elif spec is not None:
168-
url = f"https://anyplot.ai/python/{spec}"
172+
url = f"https://anyplot.ai/{spec}"
169173
else:
170174
# Fallback: missing spec for a spec-based page
171175
url = "https://anyplot.ai/"
172176

173177
props: dict[str, str] = {"page": page, "platform": platform}
174178
if spec:
175179
props["spec"] = spec
180+
if language:
181+
props["language"] = language
176182
if library:
177183
props["library"] = library
178184
if filters:

api/mcp/server.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ async def get_spec_detail(spec_id: str) -> dict[str, Any]:
273273
impl_response = ImplementationResponse(
274274
library_id=impl.library.id,
275275
library_name=impl.library.name,
276+
language=impl.library.language,
276277
preview_url=impl.preview_url,
277278
preview_html=impl.preview_html,
278279
quality_score=impl.quality_score,
@@ -291,7 +292,7 @@ async def get_spec_detail(spec_id: str) -> dict[str, Any]:
291292
implementations.append(
292293
{
293294
**impl_response.model_dump(),
294-
"website_url": f"{ANYPLOT_WEBSITE_URL}/python/{spec_id}/{impl.library.id}",
295+
"website_url": f"{ANYPLOT_WEBSITE_URL}/{spec_id}/{impl.library.language}/{impl.library.id}",
295296
}
296297
)
297298

@@ -367,6 +368,7 @@ async def get_implementation(spec_id: str, library: str) -> dict[str, Any]:
367368
response = ImplementationResponse(
368369
library_id=impl.library.id,
369370
library_name=impl.library.name,
371+
language=impl.library.language,
370372
preview_url=impl.preview_url,
371373
preview_html=impl.preview_html,
372374
quality_score=impl.quality_score,
@@ -383,7 +385,10 @@ async def get_implementation(spec_id: str, library: str) -> dict[str, Any]:
383385
impl_tags=impl.impl_tags,
384386
)
385387

386-
return {**response.model_dump(), "website_url": f"{ANYPLOT_WEBSITE_URL}/python/{spec_id}/{library}"}
388+
return {
389+
**response.model_dump(),
390+
"website_url": f"{ANYPLOT_WEBSITE_URL}/{spec_id}/{impl.library.language}/{library}",
391+
}
387392
finally:
388393
await session.close()
389394

api/routers/insights.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class TopImpl(BaseModel):
6868
spec_id: str
6969
spec_title: str
7070
library_id: str
71+
language: str
7172
quality_score: float
7273
preview_url: str | None = None
7374

@@ -105,6 +106,7 @@ class PlotOfTheDayResponse(BaseModel):
105106
description: str | None = None
106107
library_id: str
107108
library_name: str
109+
language: str
108110
quality_score: float
109111
preview_url: str | None = None
110112
image_description: str | None = None
@@ -121,6 +123,7 @@ class RelatedSpecItem(BaseModel):
121123
title: str
122124
preview_url: str | None = None
123125
library_id: str | None = None
126+
language: str | None = None
124127
similarity: float
125128
shared_tags: list[str]
126129

@@ -253,6 +256,7 @@ async def _build_dashboard(repo: SpecRepository, impl_repo: ImplRepository) -> D
253256
spec_id=spec.id,
254257
spec_title=spec.title,
255258
library_id=lib_id,
259+
language=impl.library.language if impl.library else "python",
256260
quality_score=score,
257261
preview_url=impl.preview_url,
258262
)
@@ -370,12 +374,21 @@ async def _build_potd(spec_repo: SpecRepository, impl_repo: ImplRepository) -> P
370374
today = date.today().isoformat()
371375

372376
# Collect candidates: implementations with quality_score >= 90 (lightweight, no code loaded)
373-
candidates: list[tuple[str, str, str, str, float, str]] = []
377+
candidates: list[tuple[str, str, str, str, str, float, str]] = []
374378
for spec in all_specs:
375379
for impl in spec.impls:
376380
if impl.quality_score is not None and impl.quality_score >= 90 and impl.preview_url:
381+
language = impl.library.language if impl.library else "python"
377382
candidates.append(
378-
(spec.id, spec.title, spec.description or "", impl.library_id, impl.quality_score, impl.preview_url)
383+
(
384+
spec.id,
385+
spec.title,
386+
spec.description or "",
387+
impl.library_id,
388+
language,
389+
impl.quality_score,
390+
impl.preview_url,
391+
)
379392
)
380393

381394
if not candidates:
@@ -384,7 +397,7 @@ async def _build_potd(spec_repo: SpecRepository, impl_repo: ImplRepository) -> P
384397
# Deterministic selection based on date
385398
seed = int(hashlib.md5(today.encode()).hexdigest(), 16) # noqa: S324
386399
idx = seed % len(candidates)
387-
spec_id, spec_title, description, library_id, quality_score, preview_url = candidates[idx]
400+
spec_id, spec_title, description, library_id, language, quality_score, preview_url = candidates[idx]
388401

389402
# Load deferred fields (code, image_description) for just this one impl
390403
full_impl = await impl_repo.get_by_spec_and_library(spec_id, library_id)
@@ -395,6 +408,7 @@ async def _build_potd(spec_repo: SpecRepository, impl_repo: ImplRepository) -> P
395408
description=description,
396409
library_id=library_id,
397410
library_name=LIBRARY_NAMES.get(library_id, library_id),
411+
language=language,
398412
quality_score=quality_score,
399413
preview_url=preview_url,
400414
image_description=full_impl.review_image_description if full_impl else None,
@@ -518,6 +532,7 @@ async def _build_related(
518532
title=spec.title,
519533
preview_url=best_impl.preview_url if best_impl else None,
520534
library_id=best_impl.library_id if best_impl else None,
535+
language=(best_impl.library.language if best_impl and best_impl.library else None),
521536
similarity=round(similarity, 3),
522537
shared_tags=shared_tags,
523538
)

api/routers/libraries.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ async def _refresh_libraries() -> dict:
2626
{
2727
"id": lib.id,
2828
"name": lib.name,
29+
"language": lib.language,
2930
"version": lib.version,
3031
"documentation_url": lib.documentation_url,
3132
"description": lib.description,
@@ -53,6 +54,7 @@ async def _fetch() -> dict:
5354
{
5455
"id": lib.id,
5556
"name": lib.name,
57+
"language": lib.language,
5658
"version": lib.version,
5759
"documentation_url": lib.documentation_url,
5860
"description": lib.description,

api/routers/og_images.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,19 +90,19 @@ async def _fetch_image(url: str) -> bytes:
9090
return response.content
9191

9292

93-
@router.get("/{spec_id}/{library}.png")
93+
@router.get("/{spec_id}/{language}/{library}.png")
9494
async def get_branded_impl_image(
95-
spec_id: str, library: str, request: Request, db: AsyncSession | None = Depends(optional_db)
95+
spec_id: str, language: str, library: str, request: Request, db: AsyncSession | None = Depends(optional_db)
9696
) -> Response:
9797
"""Get a branded OG image for an implementation.
9898
9999
Returns a 1200x630 PNG with anyplot.ai header and the plot image.
100100
"""
101101
# Track og:image request (fire-and-forget)
102-
track_og_image(request, page="spec_detail", spec=spec_id, library=library)
102+
track_og_image(request, page="spec_detail", spec=spec_id, language=language, library=library)
103103

104104
# Check cache first
105-
key = cache_key("og", spec_id, library)
105+
key = cache_key("og", spec_id, language, library)
106106
cached = get_cache(key)
107107
if cached:
108108
return Response(content=cached, media_type="image/png", headers={"Cache-Control": "public, max-age=3600"})
@@ -115,8 +115,10 @@ async def get_branded_impl_image(
115115
if not spec:
116116
raise HTTPException(status_code=404, detail="Spec not found")
117117

118-
# Find the implementation
119-
impl = next((i for i in spec.impls if i.library_id == library), None)
118+
# Find the implementation matching language + library
119+
impl = next(
120+
(i for i in spec.impls if i.library_id == library and i.library and i.library.language == language), None
121+
)
120122
if not impl or not impl.preview_url:
121123
raise HTTPException(status_code=404, detail="Implementation not found")
122124

api/routers/plots.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ def _collect_all_images(all_specs: list) -> list[dict]:
340340
{
341341
"spec_id": spec_obj.id,
342342
"library": impl.library_id,
343+
"language": impl.library.language if impl.library else "python",
343344
"quality": impl.quality_score,
344345
"url": impl.preview_url,
345346
"html": impl.preview_html,

0 commit comments

Comments
 (0)