refactor(routing): restructure URLs to /{specId}/{language}/{library}#5289
refactor(routing): restructure URLs to /{specId}/{language}/{library}#5289MarkusNeusinger merged 6 commits intomainfrom
Conversation
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
…acking
After the URL restructure to /{specId}/{language}/{library},
track_og_image() was still building legacy /python/{spec}/{library} URLs,
so social-bot pageviews were attributed to non-existent paths in Plausible.
- Thread `language` through track_og_image() and the spec-detail call site
in og_images.py.
- Rebuild URL from the new three-tier structure (hub / language / detail).
- Add `language` to the props sent to Plausible.
- Document the new prop in docs/reference/plausible.md (event signature,
Required Custom Properties, events summary).
- Cover the new URL/prop shape with a Plausible payload test.
https://claude.ai/code/session_01Sd9QoGJfcNU8yEhsQixDCV
Close the sitemap gaps for public routes that existed in the SPA but weren't
exposed to crawlers:
- Sitemap now lists /about and /palette alongside the other static pages.
- SEO-proxy gains /seo-proxy/about, /seo-proxy/palette, /seo-proxy/stats so
social bots get pre-rendered HTML with og:tags instead of 404.
Also repair tests that still asserted the legacy /python/{spec}[/{library}]
URL shape and the pre-language-slot /og/{spec}/{library}.png OG route —
remnants from before the /{specId}/{language}/{library} restructure that
would have failed on CI:
- tests/unit/api/test_seo_helpers.py: assert three-tier URLs, dedupe of
language-overview, explicit ban on /python/ prefix, language mock on impls.
- tests/unit/api/test_routers.py: update sitemap + seo-proxy expectations to
three-tier URLs, add about/palette/stats proxy cases, seed library.language
in the mock_spec fixture.
- tests/unit/api/test_og_images.py: call /og/{spec}/{language}/{library}.png
and seed library.language in _make_impl.
https://claude.ai/code/session_01Sd9QoGJfcNU8yEhsQixDCV
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
Refactors the URL scheme to make the spec slug the canonical top-level SEO entity, moving language into the middle path segment and folding the interactive view into the detail page via ?view=interactive.
Changes:
- Introduces
/ {specId },/ {specId }/ {language },/ {specId }/ {language }/ {library }routes across backend SEO/sitemaps/OG images and frontend routing. - Adds
Library.language(default"python") and threadslanguagethrough API responses and frontend types. - Removes
InteractivePageand merges iframe/interactive behavior intoSpecDetailView; addspython.anyplot.ainginx server block + reserved slug enforcement in spec creation workflow.
Reviewed changes
Copilot reviewed 43 out of 43 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/api/test_seo_helpers.py | Updates sitemap expectations for the new three-tier URL structure and deduped language overview URLs. |
| tests/unit/api/test_routers.py | Updates router tests for sitemap + SEO proxy canonical/OG URLs; adds SEO tests for static pages. |
| tests/unit/api/test_og_images.py | Updates OG image route tests to include {language} in the path. |
| tests/unit/api/test_analytics.py | Extends analytics test coverage to include language in spec-detail URL/props. |
| docs/reference/seo.md | Documents the new multi-language URL strategy, reserved slugs, and legacy URL behavior. |
| docs/reference/plausible.md | Updates Plausible URL strategy and OG-image tracking props for language-aware URLs. |
| core/database/models.py | Adds language column to Library model. |
| core/constants.py | Adds SUPPORTED_LANGUAGES and includes language in library seed metadata. |
| app/src/utils/paths.ts | Replaces old /python prefix helpers with specPath(specId, language?, library?) + reserved slug set + language parsing helper. |
| app/src/types/index.ts | Adds language to key frontend data models (PlotImage, LibraryInfo, Implementation). |
| app/src/router.tsx | Replaces legacy /python/* routes with :specId/:language/:library route family; removes interactive route. |
| app/src/pages/StatsPage.tsx | Updates Stats links to use the language-aware specPath. |
| app/src/pages/StatsPage.test.tsx | Updates Stats test fixtures to include language. |
| app/src/pages/SpecPage.tsx | Implements hub/language/detail modes via URL segments and adds interactive toggle via ?view=interactive. |
| app/src/pages/SpecPage.test.tsx | Updates SpecPage tests for new params and search-param handling. |
| app/src/pages/PlotsPage.tsx | Updates plot-card navigation to include language in spec detail URLs. |
| app/src/pages/LandingPage.tsx | Updates featured navigation links to include language in spec detail URLs. |
| app/src/pages/HomePage.tsx | Updates navigation to include language in spec detail URLs. |
| app/src/pages/DebugPage.tsx | Updates debug links to new URL structure (currently hardcoded to python). |
| app/src/hooks/usePlotOfTheDay.ts | Threads language through POTD hook types. |
| app/src/hooks/useFeaturedSpecs.ts | Threads language through featured spec hook types. |
| app/src/hooks/useAnalytics.ts | Updates Plausible URL building to preserve spec/language/library prefixes; imports reserved slug set. |
| app/src/components/SpecOverview.tsx | Updates interactive deep-link to use ?view=interactive on the detail URL. |
| app/src/components/SpecDetailView.tsx | Merges interactive iframe view into detail view and adds preview/interactive toggle UI. |
| app/src/components/SpecDetailView.test.tsx | Updates tests for new props and renamed interactive toggle behavior. |
| app/src/components/RelatedSpecs.tsx | Updates related-spec links to include language when linking to full detail. |
| app/src/components/PlotOfTheDayTerminal.tsx | Updates POTD terminal link to include language. |
| app/src/components/PlotOfTheDay.tsx | Updates POTD links to include language. |
| app/src/components/MastheadRule.tsx | Updates breadcrumb parsing to match new route structure and reserved routes. |
| app/src/components/Layout.tsx | Removes obsolete reference to InteractivePage persistence. |
| app/src/pages/InteractivePage.tsx | Removes the dedicated interactive fullscreen route implementation. |
| app/src/pages/InteractivePage.test.tsx | Removes tests for the deleted InteractivePage route. |
| app/nginx.conf | Adds python.anyplot.ai server block that injects /python for bot SEO proxy paths. |
| api/schemas.py | Adds language fields to API schemas returned to the frontend. |
| api/routers/specs.py | Includes language in ImplementationResponse derived from Library.language. |
| api/routers/seo.py | Rebuilds sitemap + SEO proxy endpoints around the new three-tier URL structure; adds static-page SEO endpoints. |
| api/routers/plots.py | Adds language to plot image items returned by plots endpoints. |
| api/routers/og_images.py | Changes branded OG image route to /og/{spec_id}/{language}/{library}.png and tracks language. |
| api/routers/libraries.py | Includes language in libraries API output. |
| api/routers/insights.py | Threads language through dashboard/top-impl, POTD, and related-spec responses. |
| api/analytics.py | Adds language support to OG-image tracking URL building and Plausible props. |
| alembic/versions/e1f3a2c4d5b6_add_language_to_libraries.py | Adds the language column to the libraries table with default "python". |
| .github/workflows/spec-create.yml | Blocks spec IDs that collide with reserved top-level routes. |
| xml_lines.append(f" <url><loc>https://anyplot.ai/{spec_id}</loc>{_lastmod(spec.updated)}</url>") | ||
| # Language overviews + implementation details, grouped per language | ||
| languages = sorted({impl.library.language for impl in spec.impls if impl.library}) | ||
| for language in languages: | ||
| language_esc = html.escape(language) | ||
| xml_lines.append( | ||
| f" <url><loc>https://anyplot.ai/{spec_id}/{language_esc}</loc>{_lastmod(spec.updated)}</url>" | ||
| ) | ||
| for impl in spec.impls: | ||
| if not impl.library: | ||
| continue | ||
| language_esc = html.escape(impl.library.language) | ||
| library_id = html.escape(impl.library_id) | ||
| xml_lines.append( | ||
| f" <url><loc>https://anyplot.ai/{spec_id}/{language_esc}/{library_id}</loc>" |
There was a problem hiding this comment.
_build_sitemap_xml() now relies on impl.library.language, but SpecRepository.get_all() (used by /sitemap.xml) only selectinloads Spec.impls and does not eager-load Impl.library. In async SQLAlchemy this can trigger MissingGreenlet / lazy-load errors and/or an N+1 query pattern. Consider eager-loading Impl.library for the sitemap query (e.g., selectinload(Spec.impls).selectinload(Impl.library)) or adding a dedicated repo method for sitemap generation that preloads libraries.
| /** Reserved top-level paths that must never be assigned as spec ids. */ | ||
| export const RESERVED_TOP_LEVEL = new Set([ | ||
| 'plots', | ||
| 'specs', | ||
| 'libraries', | ||
| 'palette', | ||
| 'about', | ||
| 'legal', | ||
| 'mcp', | ||
| 'stats', | ||
| 'debug', | ||
| 'sitemap.xml', | ||
| 'robots.txt', | ||
| ]); |
There was a problem hiding this comment.
RESERVED_TOP_LEVEL is documented as needing to stay in sync with the reserved slug list in .github/workflows/spec-create.yml, but the sets differ (workflow also reserves e.g. "api" and "og"). This mismatch can allow runtime slug collisions even though spec creation blocks them (or vice versa). Align the lists (ideally define once and reuse) so both frontend routing and spec creation enforce the same reserved slugs.
| (i) => i.library_id === urlLibrary && i.language === urlLanguage, | ||
| ); | ||
| if (!matched) { | ||
| navigate(specPath(specId!, urlLanguage), { replace: true }); |
There was a problem hiding this comment.
When the requested {language, library} pair doesn't exist, the effect calls navigate(specPath(specId, urlLanguage), { replace: true }) but doesn't return afterwards. This can leave the component briefly rendering the detail mode with a mismatched implementation until the navigation completes. Add a return after navigate (and/or avoid deriving currentImpl from the wrong route state) to prevent transient incorrect rendering.
| navigate(specPath(specId!, urlLanguage), { replace: true }); | |
| navigate(specPath(specId!, urlLanguage), { replace: true }); | |
| return; |
|
|
||
| export function SpecDetailView({ | ||
| specId, | ||
| specId: _specId, |
There was a problem hiding this comment.
SpecDetailView destructures specId as _specId but never uses it. With noUnusedLocals: true, this will fail the TypeScript build. Either remove specId from the destructuring/props if it's truly unused, or use specId (e.g., include it in onTrackEvent payloads so analytics events remain attributable to a spec).
| specId: _specId, |
| if (prevLibRef.current !== selectedLibrary) { | ||
| prevLibRef.current = selectedLibrary; | ||
| setZoomed(false); | ||
| setOrigin({ x: 50, y: 50 }); | ||
| } |
There was a problem hiding this comment.
This component resets state during render (if (prevLibRef.current !== selectedLibrary) { setZoomed(...); setOrigin(...); }). Updating React state while rendering is an anti-pattern and can cause warnings or render loops. Move this reset into a useEffect that depends on selectedLibrary (and update prevLibRef there) so state updates occur after render.
| if (prevLibRef.current !== selectedLibrary) { | |
| prevLibRef.current = selectedLibrary; | |
| setZoomed(false); | |
| setOrigin({ x: 50, y: 50 }); | |
| } | |
| useEffect(() => { | |
| if (prevLibRef.current !== selectedLibrary) { | |
| prevLibRef.current = selectedLibrary; | |
| setZoomed(false); | |
| setOrigin({ x: 50, y: 50 }); | |
| } | |
| }, [selectedLibrary]); |
| // Konvertiert Query-Params zu Pfad-Segmenten für Plausible | ||
| // Unterstützt mehrfache Params (AND) und Komma-Werte (OR) | ||
| // /?lib=matplotlib&lib=seaborn → /lib/matplotlib/lib/seaborn | ||
| // /?lib=matplotlib,seaborn → /lib/matplotlib,seaborn | ||
| function buildPlausibleUrl(): string { | ||
| const params = new URLSearchParams(window.location.search); | ||
| const segments: string[] = []; | ||
|
|
||
| // Detect language prefix from current path (e.g. /python/) | ||
| // Preserve the current spec/language/library path prefix if present. | ||
| // For routes like /:specId/:language[/:library] we keep the full pathname so | ||
| // filter segments are appended after it. Reserved top-level routes use no prefix. | ||
| const pathname = window.location.pathname; | ||
| const langPrefix = pathname.startsWith("/python") ? "/python" : ""; | ||
| const parts = pathname.split("/").filter(Boolean); | ||
| const pathPrefix = | ||
| parts.length > 0 && !RESERVED_TOP_LEVEL.has(parts[0]) ? `/${parts.join("/")}` : ""; | ||
|
|
||
| // Definierte Reihenfolge der Filter-Kategorien (inkl. impl-level tags) | ||
| const orderedKeys = [ |
There was a problem hiding this comment.
This file contains several non-English comments (e.g. the filter ordering comment and the earlier query-param explanation). Repository docs/code comments appear to be expected in English; please translate these to keep developer-facing text consistent.
- ruff format on insights/og_images/seo/stats + alembic migration
- test_routers: fix stats patches (drop non-existent get_cache, add ImplRepository), update /og URLs to /{lang}/{library}, set library.language on insights mocks
- test_schemas: include required language field for ImplementationResponse/LibraryInfo, expect lines_of_code in StatsResponse
- test_stats: drop obsolete cached-derivation tests; test DB path matching current _refresh_stats / _fetch
- repositories: eager-load Impl.library in SpecRepository.get_all/get_all_with_code (fixes async lazy-load risk for sitemap)
- paths.ts: add api/og to RESERVED_TOP_LEVEL to match spec-create.yml
- SpecDetailView: drop unused specId prop; move library-change reset into useEffect
- SpecPage: return after replace-navigate on language mismatch
https://claude.ai/code/session_01Sd9QoGJfcNU8yEhsQixDCV
…URLs
The MCP server still constructed ImplementationResponse without the
now-required language field and emitted legacy /python/{spec}/{lib}
website URLs. Use impl.library.language for both and update the URL
to /{spec}/{language}/{lib} to match the new routing.
https://claude.ai/code/session_01Sd9QoGJfcNU8yEhsQixDCV
| // Interactive view mode (driven by ?view=interactive) | ||
| const viewMode: 'preview' | 'interactive' = | ||
| searchParams.get('view') === 'interactive' ? 'interactive' : 'preview'; | ||
|
|
||
| const handleViewModeChange = useCallback( | ||
| (next: 'preview' | 'interactive') => { | ||
| const params = new URLSearchParams(searchParams); | ||
| if (next === 'interactive') params.set('view', 'interactive'); | ||
| else params.delete('view'); | ||
| setSearchParams(params, { replace: true }); | ||
| }, | ||
| [searchParams, setSearchParams], | ||
| ); |
There was a problem hiding this comment.
When ?view=interactive is present but the selected implementation has no preview_html, the UI falls back to the static preview but the URL remains in interactive mode. This can happen after switching libraries and results in a misleading shareable URL/state. Consider clearing the view query param (or forcing viewMode back to preview) whenever viewMode === 'interactive' and currentImpl?.preview_html is missing.
| """Get all specs with implementations (code + library eager-loaded).""" | ||
| result = await self.session.execute( | ||
| select(Spec).options( | ||
| selectinload(Spec.impls).selectinload(Impl.library), selectinload(Spec.impls).undefer(Impl.code) | ||
| ) |
There was a problem hiding this comment.
get_all_with_code() applies selectinload(Spec.impls) twice (once for .selectinload(Impl.library) and once for .undefer(Impl.code)). While SQLAlchemy will usually merge options, this duplication makes the loader config harder to read and can be simplified by reusing a single selectinload(Spec.impls) option and chaining both .selectinload() and .undefer() from it.
| xml_lines.append( | ||
| f" <url><loc>https://anyplot.ai/{spec_id}/{language_esc}</loc>{_lastmod(spec.updated)}</url>" |
There was a problem hiding this comment.
The sitemap <lastmod> for language overview URLs is currently derived from spec.updated. If an implementation in a given language is updated after the spec metadata, the language overview page’s lastmod will be stale, which can reduce crawl/update frequency. Consider computing the language overview lastmod from the most recent impl.updated within that language (optionally falling back to spec.updated when impl timestamps are missing).
| xml_lines.append( | |
| f" <url><loc>https://anyplot.ai/{spec_id}/{language_esc}</loc>{_lastmod(spec.updated)}</url>" | |
| language_impls = [ | |
| impl for impl in spec.impls if impl.library and impl.library.language == language | |
| ] | |
| language_updates = [impl.updated for impl in language_impls if impl.updated is not None] | |
| language_lastmod = max(language_updates) if language_updates else spec.updated | |
| xml_lines.append( | |
| f" <url><loc>https://anyplot.ai/{spec_id}/{language_esc}</loc>{_lastmod(language_lastmod)}</url>" |
- SpecPage: drop ?view=interactive from the URL when the active impl has no preview_html, so shareable links match the rendered fallback. - SpecRepository.get_all_with_code: share a single selectinload(Spec.impls) loader for both library eager-load and code undefer. - Sitemap: derive language-overview <lastmod> from the most recent impl in that language, falling back to spec.updated when no impl is dated. https://claude.ai/code/session_01Sd9QoGJfcNU8yEhsQixDCV
Move language from a top-level URL prefix (
/python/...) to a middlesegment 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.languagecolumn (default "python"), threadedthrough
ImplementationResponse,LibraryInfo,PlotImage,TopImpl,RelatedSpecItem,PlotOfTheDayResponse. Sitemap now emits all threeURL tiers; SEO proxy and OG image routes restructured accordingly.
Frontend:
specPath(specId, language?, library?)builds the dynamicpath;
RESERVED_TOP_LEVELblocks slug collisions.MastheadRuleanduseAnalyticsparse path segments instead of hard-coding/python.InteractivePageremoved; iframe logic merged intoSpecDetailView.Subdomain:
python.anyplot.aiserver block added with an internalnginx 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.ymlrejects spec IDs that collide with reservedtop-level routes.
https://claude.ai/code/session_01Sd9QoGJfcNU8yEhsQixDCV