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(' 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('{payload}' + + +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 @@ - -