Re-implement SEO support for website (ported to website/components/ structure) - #137
Re-implement SEO support for website (ported to website/components/ structure)#137evnchn wants to merge 4 commits into
Conversation
Re-implementation of ex-upstream PR zauberzeug#5813 against the new website/components/ structure introduced by upstream zauberzeug#5910. - New website/seo.py: meta/OG/Twitter/canonical tag builders, breadcrumb JSON-LD, markdown/rst description extractor - Per-page wiring in main_page, examples_page, imprint_privacy, and documentation.rendering (dispatcher-level integration) - Organization + SoftwareApplication JSON-LD injected from header.add_head_html so every page carries it - /robots.txt and /sitemap.xml routes in main.py, sitemap generated from documentation.registry - website/tests/test_seo.py added to pytest testpaths - website/static/header.html: remove static meta description (now emitted per-page by seo.page_seo_html) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
website/tests/ lacked the tests/conftest.py fixtures, leaving unraisable coroutines dangling from earlier tests. Dropping the extra testpath fixes test_module_import_isolation_first_test.
…lection Empty out website/__init__.py so importing a leaf module such as `website.seo` no longer drags in `website.documentation` and friends. Those packages call `with Client(...)` at import time to pre-render documentation UI, which schedules `Outbox.loop` via `background_tasks.create_or_defer`. When no event loop is running (pytest collection) the coroutine is deferred to `app.on_startup`, never awaited, and finalized by the GC - producing ~25 `RuntimeWarning: coroutine 'Outbox.loop' was never awaited` entries that the pytest `unraisableexception` plugin re-raises in the setup hook of the first test (`test_module_import_isolation_first_test`). `main.py` already imports the specific submodules it needs (`from website import documentation, examples_page, ...`), so an empty `__init__.py` is sufficient for the production website.
There was a problem hiding this comment.
Pull request overview
Re-implements SEO support for the NiceGUI documentation website under the newer website/components-based structure by generating per-page meta tags/structured data and adding robots.txt + sitemap.xml endpoints.
Changes:
- Add centralized SEO helpers (
page_seo_html,breadcrumb_jsonld,extract_description) and accompanying unit tests. - Wire per-page SEO metadata (description/canonical/OpenGraph/Twitter + breadcrumbs) into main, examples, imprint/privacy, and documentation rendering flows.
- Add
/robots.txtand/sitemap.xml, and adjustwebsite/__init__.pyto avoid eager imports that trigger pytest warnings.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| website/static/header.html | Removes hardcoded meta description so descriptions can be page-specific. |
| website/seo.py | Introduces helpers for meta tags, JSON-LD breadcrumbs, and description extraction. |
| website/main_page.py | Adds page title + SEO head injection for the homepage. |
| website/imprint_privacy.py | Adds SEO head injection + breadcrumbs for imprint/privacy page. |
| website/header.py | Adds sitewide JSON-LD (Organization + SoftwareApplication) to the shared header head HTML. |
| website/examples_page.py | Adds SEO head injection + breadcrumbs for examples page. |
| website/documentation/rendering.py | Derives per-doc-page descriptions/titles and injects SEO head + breadcrumbs. |
| website/init.py | Removes eager imports to prevent side effects during test collection. |
| tests/test_seo.py | Adds tests for description extraction, SEO HTML generation, and JSON-LD escaping. |
| main.py | Adds robots.txt and sitemap.xml endpoints and imports website.seo constants. |
| title = 'NiceGUI Examples - Python UI Code Samples and Demos' | ||
| description = ('Browse in-depth NiceGUI examples including authentication, chat apps, todo lists, and more. ' | ||
| 'See real Python GUI code with live demos.') | ||
| ui.page_title(title) | ||
| ui.add_head_html(page_seo_html(title=title, description=description, path='/examples')) | ||
| ui.add_head_html(breadcrumb_jsonld([('Home', '/'), ('Examples', '/examples')])) |
There was a problem hiding this comment.
ui.add_head_html(...) is executed on every sub-page render. Because NiceGUI's add_head_html appends to document.head, client-side navigation will accumulate duplicate meta tags/canonical links and breadcrumb JSON-LD. Consider removing/overwriting previously injected SEO elements instead of always inserting new ones.
There was a problem hiding this comment.
Valid. Fixed in 547d136: introduced apply_page_seo(...) in website/seo.py. Each emitted tag carries a data-nicegui-seo attribute; the first call per client uses ui.add_head_html (so SSR HTML has them for crawlers), subsequent SPA-nav calls remove tagged elements via ui.run_javascript before re-inserting, preventing accumulation.
| def create() -> None: | ||
| title = 'Imprint & Privacy Policy - NiceGUI' | ||
| description = 'Legal information, imprint, and privacy policy for NiceGUI by Zauberzeug GmbH.' | ||
| ui.page_title('Imprint & Privacy | NiceGUI') |
There was a problem hiding this comment.
The HTML title set via ui.page_title(...) does not match the SEO title passed to page_seo_html(...) (Imprint & Privacy | NiceGUI vs Imprint & Privacy Policy - NiceGUI). This creates inconsistent <title>/OpenGraph/Twitter titles for the same page. Align these values (or derive one from the other) so a single canonical title is used across tags.
| ui.page_title('Imprint & Privacy | NiceGUI') | |
| ui.page_title(title) |
There was a problem hiding this comment.
Valid. Fixed in 547d136: ui.page_title(title) now uses the same title variable as the SEO title, so <title>/OpenGraph/Twitter are consistent.
| ui.add_head_html(page_seo_html(title=title, description=description, path='/imprint_privacy')) | ||
| ui.add_head_html(breadcrumb_jsonld([('Home', '/'), ('Imprint & Privacy', '/imprint_privacy')])) |
There was a problem hiding this comment.
These ui.add_head_html(...) calls run inside a ui.sub_pages route handler. Since add_head_html appends/inserts into document.head, navigating within the SPA will add duplicate canonical/OG/Twitter tags and breadcrumb JSON-LD blocks over time. Consider cleaning up previously injected SEO tags (e.g., remove elements with a dedicated marker attribute) before adding new ones.
There was a problem hiding this comment.
Valid. Fixed in 547d136: introduced apply_page_seo(...) in website/seo.py. Each emitted tag carries a data-nicegui-seo attribute; the first call per client uses ui.add_head_html (so SSR HTML has them for crawlers), subsequent SPA-nav calls remove tagged elements via ui.run_javascript before re-inserting, preventing accumulation.
| description = _build_page_description(documentation) | ||
| path = f'/documentation/{documentation.name}' if documentation.name else '/documentation' | ||
| ui.add_head_html(page_seo_html(title=seo_title, description=description, path=path, og_type='article')) | ||
|
|
||
| breadcrumbs = [('Home', '/'), ('Documentation', '/documentation')] | ||
| if documentation.name: | ||
| if documentation.back_link is not None: | ||
| from .content import registry | ||
| parent = registry.get(documentation.back_link) | ||
| if parent and parent.title: | ||
| parent_title = parent.title.replace('*', '') | ||
| breadcrumbs.append((parent_title, f'/documentation/{documentation.back_link}')) | ||
| breadcrumbs.append((title, path)) | ||
| ui.add_head_html(breadcrumb_jsonld(breadcrumbs)) |
There was a problem hiding this comment.
ui.add_head_html(...) is called as part of sub-page rendering. With ui.sub_pages SPA navigation, add_head_html keeps appending into document.head, so documentation navigation can accumulate many duplicate canonical/OG/Twitter tags and breadcrumb JSON-LD scripts. Consider switching to an update-in-place approach (remove/replace previously injected SEO elements) to avoid head bloat and ambiguous metadata.
There was a problem hiding this comment.
Valid. Fixed in 547d136: introduced apply_page_seo(...) in website/seo.py. Each emitted tag carries a data-nicegui-seo attribute; the first call per client uses ui.add_head_html (so SSR HTML has them for crawlers), subsequent SPA-nav calls remove tagged elements via ui.run_javascript before re-inserting, preventing accumulation.
| from website.seo import breadcrumb_jsonld, extract_description, page_seo_html | ||
|
|
There was a problem hiding this comment.
PR description says "What stays: ... website/tests/test_seo.py", but the tests added/modified here are under tests/test_seo.py (and there is no website/tests/ directory). Please update the PR description (or move the test file) so reviewers/users can find the correct location.
There was a problem hiding this comment.
Valid. PR description updated: path now correctly reads tests/test_seo.py (no stale website/tests/ reference).
| title = 'NiceGUI - Easy-to-Use Python-Based UI Framework' | ||
| ui.page_title(title) | ||
| ui.add_head_html(page_seo_html(title=title, description=DEFAULT_DESCRIPTION, path='/')) | ||
| ui.add_head_html(breadcrumb_jsonld([('Home', '/')])) |
There was a problem hiding this comment.
ui.add_head_html(...) is called inside a ui.sub_pages route builder. In SPA navigation, add_head_html appends/inserts into document.head without removing previous tags, so navigating between sub-pages will accumulate duplicate canonical/OG/Twitter tags and multiple breadcrumb JSON-LD blocks. Consider replacing/removing previously injected SEO tags (e.g., mark them with a data-... attribute and remove via ui.run_javascript before inserting, or implement an update-in-place helper instead of repeated add_head_html).
There was a problem hiding this comment.
Valid. Fixed in 547d136: introduced apply_page_seo(...) in website/seo.py. Each emitted tag carries a data-nicegui-seo attribute; the first call per client uses ui.add_head_html (so SSR HTML has them for crawlers), subsequent SPA-nav calls remove tagged elements via ui.run_javascript before re-inserting, preventing accumulation.
Per-page SEO was injected via `ui.add_head_html(...)` inside sub_pages route handlers. Because SPA navigation keeps appending to document.head (and to the server-side client._head_html buffer), repeated navigation between pages duplicated every meta/canonical/og/twitter tag and every breadcrumb JSON-LD block in the live DOM. Introduce `apply_page_seo(...)` that tags every emitted element with a `data-nicegui-seo` attribute. The first call per client uses `add_head_html` so the tags land in the SSR HTML that crawlers see; subsequent calls patch the live DOM via `ui.run_javascript`, removing previously injected tags before appending the new set. Callers in main_page, examples_page, imprint_privacy, and documentation/rendering switch to the helper. Also align `ui.page_title` on the imprint/privacy page with the SEO title (previously "Imprint & Privacy | NiceGUI" vs. "Imprint & Privacy Policy - NiceGUI") so there is a single canonical title across <title>/OpenGraph/Twitter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Re-implementation of ex-upstream zauberzeug#5813 against new website/ structure from zauberzeug#5910.
What stays: website/seo.py, website/static/header.html, tests/test_seo.py.
What changed: SEO wiring distributed across new website/components/*.py.
Status: Fork PR for user review before upstream re-submission.