diff --git a/main.py b/main.py
index 2679d6b8e1..15965f1f8b 100755
--- a/main.py
+++ b/main.py
@@ -1,8 +1,10 @@
#!/usr/bin/env python3
import os
from pathlib import Path
+from xml.sax.saxutils import escape as xml_escape
from fastapi import Request
+from fastapi.responses import PlainTextResponse
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import Response
@@ -10,7 +12,7 @@
from nicegui import app, core, ui
from nicegui.page_arguments import RouteMatch
from website import design as d
-from website import documentation, examples_page, fly, header, imprint_privacy, main_page, rate_limits, svg
+from website import documentation, examples_page, fly, header, imprint_privacy, main_page, rate_limits, seo, svg
from website.components import footer_section
from website.documentation.intersection_observer import IntersectionObserver as intersection_observer
@@ -43,6 +45,42 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -
documentation.build_tree()
+@app.get('/robots.txt')
+def _robots_txt() -> PlainTextResponse:
+ return PlainTextResponse(
+ f'User-agent: *\nAllow: /\n\nSitemap: {seo.SITE_URL}/sitemap.xml\n',
+ media_type='text/plain',
+ )
+
+
+@app.get('/sitemap.xml')
+def _sitemap_xml() -> Response:
+ urls = [
+ ('/', '1.0', 'weekly'),
+ ('/documentation', '0.9', 'weekly'),
+ ('/examples', '0.8', 'monthly'),
+ ('/imprint_privacy', '0.3', 'yearly'),
+ ]
+ for name in documentation.registry:
+ if name: # skip the overview page (already added as /documentation)
+ urls.append((f'/documentation/{name}', '0.7', 'monthly'))
+ xml_urls = '\n'.join(
+ f' \n'
+ f' {xml_escape(seo.SITE_URL + path)}\n'
+ f' {freq}\n'
+ f' {priority}\n'
+ f' '
+ for path, priority, freq in urls
+ )
+ xml = (
+ '\n'
+ '\n'
+ f'{xml_urls}\n'
+ '\n'
+ )
+ return Response(content=xml, media_type='application/xml')
+
+
@app.post('/dark_mode')
async def _post_dark_mode(request: Request) -> None:
app.storage.browser['dark_mode'] = (await request.json()).get('value')
diff --git a/tests/test_seo.py b/tests/test_seo.py
new file mode 100644
index 0000000000..d42240c0f4
--- /dev/null
+++ b/tests/test_seo.py
@@ -0,0 +1,178 @@
+from website.seo import breadcrumb_jsonld, extract_description, page_seo_html
+
+
+def test_extract_description_plain_text():
+ text = 'This is a simple description that is long enough to pass the minimum length threshold for extraction.'
+ result = extract_description(text)
+ assert result == text
+
+
+def test_extract_description_returns_none_for_short_text():
+ assert extract_description('Too short') is None
+ assert extract_description('') is None
+
+
+def test_extract_description_strips_markdown_bold():
+ text = 'This is **bold text** that should be cleaned up properly by the extraction function.'
+ result = extract_description(text)
+ assert '**' not in result
+ assert 'bold text' in result
+
+
+def test_extract_description_strips_markdown_italic():
+ text = 'This is _italic text_ that should be cleaned up properly by the extraction function.'
+ result = extract_description(text)
+ assert result is not None
+ assert '_italic' not in result
+ assert 'italic text' in result
+
+
+def test_extract_description_strips_markdown_links():
+ text = 'Click [this link](https://example.com) to visit the site and learn about the features.'
+ result = extract_description(text)
+ assert result is not None
+ assert 'this link' in result
+ assert 'https://example.com' not in result
+ assert '[' not in result
+ assert '](' not in result
+
+
+def test_extract_description_strips_rst_links():
+ text = 'See `NiceGUI documentation `_ for more information about all features.'
+ result = extract_description(text)
+ assert result is not None
+ assert 'NiceGUI documentation' in result
+ assert 'https://nicegui.io' not in result
+ assert '`' not in result
+
+
+def test_extract_description_strips_backtick_code():
+ text = 'Use the `ui.button` element to create interactive buttons in your Python application.'
+ result = extract_description(text)
+ assert result is not None
+ assert 'ui.button' in result
+ assert '`' not in result
+
+
+def test_extract_description_strips_html_tags():
+ text = 'This has bold HTML and a link that need to be properly cleaned.'
+ result = extract_description(text)
+ assert result is not None
+ assert '' not in result
+ assert '' in result
+ assert 'BreadcrumbList' in result
+ assert '"position":1' in result
+ assert '"position":2' in result
+ assert '"name":"Home"' in result
+ assert '"name":"Docs"' in result
+
+
+def test_breadcrumb_jsonld_includes_full_urls():
+ result = breadcrumb_jsonld([('Home', '/'), ('Docs', '/docs')])
+ assert 'https://nicegui.io/' in result
+ assert 'https://nicegui.io/docs' in result
+
+
+def test_breadcrumb_jsonld_escapes_closing_script():
+ result = breadcrumb_jsonld([('Test', '/test')])
+ assert '<' not in result.replace('', '', 1) # only the closing tag itself
diff --git a/website/__init__.py b/website/__init__.py
index 7fcf7db47d..4a36f370c0 100644
--- a/website/__init__.py
+++ b/website/__init__.py
@@ -1,9 +1,14 @@
-from . import documentation, examples_page, fly, main_page, svg
+"""NiceGUI documentation website package.
-__all__ = [
- 'documentation',
- 'examples_page',
- 'fly',
- 'main_page',
- 'svg',
-]
+This ``__init__`` intentionally does not eagerly import submodules.
+``main.py`` imports the submodules it needs explicitly, and eagerly
+importing them here pulls in documentation modules that instantiate
+``Client`` objects at module load time (to pre-render UI snippets).
+Those ``Client`` objects schedule an ``Outbox.loop`` coroutine via
+``background_tasks.create_or_defer``; when there is no running event
+loop (e.g. during pytest collection of ``tests/test_seo.py``), the
+coroutines are deferred to ``app.on_startup``, never awaited, and
+eventually garbage-collected, producing ``RuntimeWarning`` entries
+that break the ``unraisableexception`` plugin's setup hook for the
+first test.
+"""
diff --git a/website/documentation/rendering.py b/website/documentation/rendering.py
index fd0b1278a3..ae146cc10f 100644
--- a/website/documentation/rendering.py
+++ b/website/documentation/rendering.py
@@ -1,17 +1,79 @@
+import functools
+
from nicegui import ui
from ..design import section_heading, subheading
+from ..seo import DEFAULT_DESCRIPTION, TAGLINE, apply_page_seo, extract_description
from .content import DocumentationPage
+from .content.overview import tiles
from .custom_restructured_text import CustomRestructuredText as custom_restructured_text
from .demo import demo
from .reference import generate_class_doc
+@functools.cache
+def _get_tile_descriptions() -> dict[str, str]:
+ from .content import registry # NOTE: deferred to avoid circular import
+ result: dict[str, str] = {}
+ for module, description in tiles:
+ name = module.__name__.rsplit('.', 1)[-1]
+ desc = extract_description(description)
+ if name in registry and desc is not None:
+ result[name] = desc
+ return result
+
+
+def _build_page_description(documentation: DocumentationPage) -> str:
+ tile_desc = _get_tile_descriptions().get(documentation.name)
+ if tile_desc:
+ return tile_desc
+ for part in documentation.parts:
+ if part.description and not part.link:
+ desc = extract_description(part.description)
+ if desc is not None:
+ return desc
+ if part.search_text and not part.link:
+ desc = extract_description(part.search_text)
+ if desc is not None:
+ return desc
+ if documentation.subtitle:
+ desc = extract_description(documentation.subtitle)
+ if desc is not None:
+ return desc
+ for part in documentation.parts:
+ if part.description:
+ desc = extract_description(part.description)
+ if desc is not None:
+ return desc
+ return DEFAULT_DESCRIPTION
+
+
def render_page(documentation: DocumentationPage) -> None:
"""Render the documentation."""
title = (documentation.title or '').replace('*', '')
+ if not title:
+ seo_title = f'NiceGUI Documentation - {TAGLINE}'
+ elif title.split()[0] == 'NiceGUI':
+ seo_title = f'{title} - {TAGLINE}'
+ else:
+ seo_title = f'{title} - NiceGUI Documentation'
ui.page_title('NiceGUI' if not title else title if title.split()[0] == 'NiceGUI' else f'{title} | NiceGUI')
+ description = _build_page_description(documentation)
+ path = f'/documentation/{documentation.name}' if documentation.name else '/documentation'
+
+ 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))
+ apply_page_seo(title=seo_title, description=description, path=path,
+ breadcrumbs=breadcrumbs, og_type='article')
+
def render_content():
first_demo_seen = False
section_heading(documentation.subtitle or '', documentation.heading)
diff --git a/website/examples_page.py b/website/examples_page.py
index a084e39341..4eb9eb1ec1 100644
--- a/website/examples_page.py
+++ b/website/examples_page.py
@@ -3,9 +3,16 @@
from .components.examples_section import example_card
from .design import section_heading
from .examples import examples
+from .seo import apply_page_seo
def create() -> None:
+ 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)
+ apply_page_seo(title=title, description=description, path='/examples',
+ breadcrumbs=[('Home', '/'), ('Examples', '/examples')])
with ui.column().classes('w-full p-8 lg:p-16 max-w-[1600px] mx-auto'):
section_heading('In-depth examples', 'Pick your *solution*')
with ui.grid().classes('w-full grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4'):
diff --git a/website/header.py b/website/header.py
index 590da49943..c6b0c13cc2 100644
--- a/website/header.py
+++ b/website/header.py
@@ -1,3 +1,4 @@
+import json
import os
from pathlib import Path
@@ -10,6 +11,32 @@
from . import github_stars
from .design import phosphor_icon
from .search import Search
+from .seo import DEFAULT_DESCRIPTION, SITE_URL
+
+JSON_LD_ORGANIZATION = json.dumps({
+ '@context': 'https://schema.org',
+ '@type': 'Organization',
+ 'name': 'Zauberzeug GmbH',
+ 'url': SITE_URL,
+ 'logo': f'{SITE_URL}/logo_square.png',
+ 'sameAs': [
+ 'https://github.com/zauberzeug/nicegui',
+ 'https://discord.gg/TEpFeAaF4f',
+ 'https://www.reddit.com/r/nicegui/',
+ ],
+}, separators=(',', ':')).replace('', '<\\/')
+
+JSON_LD_SOFTWARE = json.dumps({
+ '@context': 'https://schema.org',
+ '@type': 'SoftwareApplication',
+ 'name': 'NiceGUI',
+ 'applicationCategory': 'DeveloperApplication',
+ 'operatingSystem': 'Any',
+ 'description': DEFAULT_DESCRIPTION,
+ 'url': SITE_URL,
+ 'offers': {'@type': 'Offer', 'price': '0', 'priceCurrency': 'USD'},
+ 'author': {'@type': 'Organization', 'name': 'Zauberzeug GmbH'},
+}, separators=(',', ':')).replace('', '<\\/')
HEADER_HTML = (Path(__file__).parent / 'static' / 'header.html').read_text(encoding='utf-8')
STYLE_CSS = (Path(__file__).parent / 'static' / 'style.css').read_text(encoding='utf-8')
@@ -47,6 +74,10 @@ class SolarizedDark(SolarizedDarkStyle):
def add_head_html() -> None:
"""Add the code from header.html and reference style.css."""
ui.add_head_html(HEADER_HTML)
+ ui.add_head_html(
+ f''
+ f''
+ )
ui.add_head_html(FONT_LINKS)
ui.add_css(STYLE_CSS)
ui.add_css(f'''
diff --git a/website/imprint_privacy.py b/website/imprint_privacy.py
index ab2912ec43..8371c4ce7b 100644
--- a/website/imprint_privacy.py
+++ b/website/imprint_privacy.py
@@ -2,10 +2,15 @@
from . import design as d
from .components.shared import section
+from .seo import apply_page_seo
def create() -> None:
- ui.page_title('Imprint & Privacy | NiceGUI')
+ title = 'Imprint & Privacy Policy - NiceGUI'
+ description = 'Legal information, imprint, and privacy policy for NiceGUI by Zauberzeug GmbH.'
+ ui.page_title(title)
+ apply_page_seo(title=title, description=description, path='/imprint_privacy',
+ breadcrumbs=[('Home', '/'), ('Imprint & Privacy', '/imprint_privacy')])
with section('imprint'):
ui.link_target('imprint')
diff --git a/website/main_page.py b/website/main_page.py
index 7ea83cfdfd..0307c93b23 100644
--- a/website/main_page.py
+++ b/website/main_page.py
@@ -11,10 +11,15 @@
sponsors_section,
why_section,
)
+from .seo import DEFAULT_DESCRIPTION, apply_page_seo
def create() -> None:
"""Create the content of the main page."""
+ title = 'NiceGUI - Easy-to-Use Python-Based UI Framework'
+ ui.page_title(title)
+ apply_page_seo(title=title, description=DEFAULT_DESCRIPTION, path='/', breadcrumbs=[('Home', '/')])
+
ui.run_javascript('''
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
diff --git a/website/seo.py b/website/seo.py
new file mode 100644
index 0000000000..48cfcc6cf1
--- /dev/null
+++ b/website/seo.py
@@ -0,0 +1,136 @@
+"""SEO utilities for the NiceGUI documentation website."""
+import html
+import json
+import re
+
+from nicegui import ui
+
+SITE_URL = 'https://nicegui.io'
+SITE_NAME = 'NiceGUI'
+TAGLINE = 'Python-Based UI Framework'
+DEFAULT_DESCRIPTION = (
+ 'NiceGUI is an easy-to-use, Python-based UI framework, '
+ 'which shows up in your web browser. '
+ 'Create buttons, dialogs, Markdown, 3D scenes, plots and much more.'
+)
+OG_IMAGE_URL = f'{SITE_URL}/logo_square.png'
+OG_IMAGE_WIDTH = 290
+OG_IMAGE_HEIGHT = 290
+MIN_DESCRIPTION_LENGTH = 50
+
+
+SEO_MARKER = 'data-nicegui-seo'
+
+
+def meta(name: str, content: str) -> str:
+ return f''
+
+
+def meta_property(prop: str, content: str) -> str:
+ return f''
+
+
+def canonical_link(path: str) -> str:
+ url = SITE_URL + path
+ return f''
+
+
+def open_graph_tags(*, title: str, description: str, url: str, og_type: str = 'website') -> str:
+ return '\n'.join([
+ meta_property('og:title', title),
+ meta_property('og:description', description),
+ meta_property('og:url', url),
+ meta_property('og:type', og_type),
+ meta_property('og:site_name', SITE_NAME),
+ meta_property('og:locale', 'en_US'),
+ meta_property('og:image', OG_IMAGE_URL),
+ meta_property('og:image:alt', f'{SITE_NAME} logo'),
+ meta_property('og:image:width', str(OG_IMAGE_WIDTH)),
+ meta_property('og:image:height', str(OG_IMAGE_HEIGHT)),
+ ])
+
+
+def twitter_card_tags(*, title: str, description: str) -> str:
+ return '\n'.join([
+ meta('twitter:card', 'summary'),
+ meta('twitter:title', title),
+ meta('twitter:description', description),
+ meta('twitter:image', OG_IMAGE_URL),
+ ])
+
+
+def page_seo_html(*, title: str, description: str, path: str, og_type: str = 'website') -> str:
+ url = SITE_URL + path
+ parts = [
+ meta('description', description),
+ canonical_link(path),
+ open_graph_tags(title=title, description=description, url=url, og_type=og_type),
+ twitter_card_tags(title=title, description=description),
+ ]
+ return '\n'.join(parts)
+
+
+def breadcrumb_jsonld(items: list[tuple[str, str]]) -> str:
+ """Generate BreadcrumbList JSON-LD structured data.
+
+ :param items: list of (name, path) tuples for each breadcrumb level
+ """
+ ld = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ 'itemListElement': [
+ {
+ '@type': 'ListItem',
+ 'position': i + 1,
+ 'name': name,
+ 'item': SITE_URL + path,
+ }
+ for i, (name, path) in enumerate(items)
+ ],
+ }
+ payload = json.dumps(ld, separators=(',', ':')).replace('', '<\\/')
+ return f''
+
+
+def apply_page_seo(*, title: str, description: str, path: str,
+ breadcrumbs: list[tuple[str, str]], og_type: str = 'website') -> None:
+ """Apply per-page SEO tags to the document head.
+
+ The first call per client uses ``ui.add_head_html`` so the tags are present
+ in the SSR HTML that crawlers see. Subsequent calls (triggered by SPA
+ navigation through ``ui.sub_pages``) patch the live DOM via JavaScript,
+ removing previously injected tags (identified by ``data-nicegui-seo``)
+ before appending the new ones -- preventing duplicate meta/link/JSON-LD
+ entries from accumulating in ``document.head``.
+ """
+ html_block = (page_seo_html(title=title, description=description, path=path, og_type=og_type)
+ + '\n' + breadcrumb_jsonld(breadcrumbs))
+ client = ui.context.client
+ if getattr(client, '_seo_applied', False):
+ ui.run_javascript(
+ f'document.head.querySelectorAll("[{SEO_MARKER}]").forEach(e => e.remove());'
+ f'document.head.insertAdjacentHTML("beforeend", {json.dumps(html_block)});'
+ )
+ else:
+ ui.add_head_html(html_block)
+ client._seo_applied = True # pylint: disable=protected-access
+
+
+def extract_description(text: str, max_length: int = 160) -> str | None:
+ """Extract a clean description from markdown/rst text, or None if too short."""
+ text = re.split(r'\n\s*:param\s', text, maxsplit=1)[0]
+ text = re.split(r'\n\s*:type\s', text, maxsplit=1)[0]
+ text = re.split(r'\n\s*:returns?\s', text, maxsplit=1)[0]
+ text = re.sub(r'`([^`<]+)\s*<[^>]+>`_', r'\1', text) # rst link: `text `_ -> text
+ text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text) # md link: [text](url) -> text
+ text = re.sub(r'`([^`]*)`', r'\1', text) # `code` -> code
+ text = re.sub(r'`', '', text)
+ text = re.sub(r'\*+([^*]+)\*+', r'\1', text) # *bold*/**bold** -> bold
+ text = re.sub(r'(? italic
+ text = re.sub(r'<[^>]+>', '', text) # remove HTML tags
+ text = re.sub(r'\s+', ' ', text).strip()
+ if not text or len(text) < MIN_DESCRIPTION_LENGTH:
+ return None
+ if len(text) > max_length:
+ text = text[:max_length - 3].rsplit(' ', 1)[0] + '...'
+ return text
diff --git a/website/static/header.html b/website/static/header.html
index 93562c8d60..abeef95914 100644
--- a/website/static/header.html
+++ b/website/static/header.html
@@ -1,8 +1,3 @@
-
-