Skip to content

refactor(routing): restructure URLs to /{specId}/{language}/{library}#5289

Merged
MarkusNeusinger merged 6 commits intomainfrom
claude/url-structure-multilang-LaNZY
Apr 20, 2026
Merged

refactor(routing): restructure URLs to /{specId}/{language}/{library}#5289
MarkusNeusinger merged 6 commits intomainfrom
claude/url-structure-multilang-LaNZY

Conversation

@MarkusNeusinger
Copy link
Copy Markdown
Owner

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

claude added 3 commits April 20, 2026 09:25
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
Copilot AI review requested due to automatic review settings April 20, 2026 10:48
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 threads language through API responses and frontend types.
  • Removes InteractivePage and merges iframe/interactive behavior into SpecDetailView; adds python.anyplot.ai nginx 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.

Comment thread api/routers/seo.py
Comment on lines +52 to +66
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>"
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Copilot uses AI. Check for mistakes.
Comment thread app/src/utils/paths.ts Outdated
Comment on lines +14 to +27
/** 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',
]);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
(i) => i.library_id === urlLibrary && i.language === urlLanguage,
);
if (!matched) {
navigate(specPath(specId!, urlLanguage), { replace: true });
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
navigate(specPath(specId!, urlLanguage), { replace: true });
navigate(specPath(specId!, urlLanguage), { replace: true });
return;

Copilot uses AI. Check for mistakes.
Comment thread app/src/components/SpecDetailView.tsx Outdated

export function SpecDetailView({
specId,
specId: _specId,
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
specId: _specId,

Copilot uses AI. Check for mistakes.
Comment thread app/src/components/SpecDetailView.tsx Outdated
Comment on lines 72 to 76
if (prevLibRef.current !== selectedLibrary) {
prevLibRef.current = selectedLibrary;
setZoomed(false);
setOrigin({ x: 50, y: 50 });
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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]);

Copilot uses AI. Check for mistakes.
Comment on lines 19 to 36
// 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 = [
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
claude added 2 commits April 20, 2026 11:50
- 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
Copilot AI review requested due to automatic review settings April 20, 2026 12:55
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 49 out of 49 changed files in this pull request and generated 3 comments.

Comment on lines +160 to 172
// 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],
);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread core/database/repositories.py Outdated
Comment on lines +136 to +140
"""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)
)
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread api/routers/seo.py Outdated
Comment on lines +57 to +58
xml_lines.append(
f" <url><loc>https://anyplot.ai/{spec_id}/{language_esc}</loc>{_lastmod(spec.updated)}</url>"
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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>"

Copilot uses AI. Check for mistakes.
- 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
@MarkusNeusinger MarkusNeusinger merged commit e24a7cd into main Apr 20, 2026
4 of 5 checks passed
@MarkusNeusinger MarkusNeusinger deleted the claude/url-structure-multilang-LaNZY branch April 20, 2026 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants