diff --git a/dash/_pages.py b/dash/_pages.py index be9d847309..8eaa258d75 100644 --- a/dash/_pages.py +++ b/dash/_pages.py @@ -380,24 +380,48 @@ def _path_to_page(path_id): return {}, None -def _page_meta_tags(app, request): - request_path = request.path - start_page, path_variables = _path_to_page(request_path.strip("/")) +def _get_image_url(app, request, image, supplied_image_url): + if supplied_image_url: + return supplied_image_url - image = start_page.get("image", "") if image: + if image.startswith(("http://", "https://")): + return image + image = app.get_asset_url(image) - assets_image_url = "".join([request.root, image.lstrip("/")]) if image else None - supplied_image_url = start_page.get("image_url") - image_url = supplied_image_url if supplied_image_url else assets_image_url + return "".join([request.root, image.lstrip("/")]) + + return None - title = start_page.get("title", app.title) - if callable(title): - title = title(**path_variables) if path_variables else title() - description = start_page.get("description", "") - if callable(description): - description = description(**path_variables) if path_variables else description() +def _page_meta_tags(app, request): + if not app.use_pages and not (app.description or app.image): + return [] + + title = app.title + description = app.description or "" + image = app.image + supplied_image_url = None + + if app.use_pages: + request_path = request.path + start_page, path_variables = _path_to_page(request_path.strip("/")) + + if start_page: + title = start_page.get("title", title) + description = start_page.get("description", description) + image = start_page.get("image") or image + supplied_image_url = start_page.get("image_url") + + if callable(title): + title = title(**path_variables) if path_variables else title() + + if callable(description): + description = ( + description(**path_variables) if path_variables else description() + ) + + image_url = _get_image_url(app, request, image, supplied_image_url) return [ {"name": "description", "content": description}, diff --git a/dash/dash.py b/dash/dash.py index 19892085a0..24849a2f6e 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -285,9 +285,23 @@ class Dash(ObsoleteChecker): to be True. Default `None`. :type use_pages: boolean - :param include_pages_meta: Include the page meta tags for twitter cards. + :param include_pages_meta: Include the meta tags for social media cards. :type include_pages_meta: bool + :param title: Default ``Dash``. Configures the document.title + (the text that appears in a browser tab). + + :param update_title: Default ``Updating...``. Configures the document.title + (the text that appears in a browser tab) text when a callback is being run. + Set to None or '' if you don't want the document.title to change or if you + want to control the document.title through a separate component or + clientside callback. + + :param description: Sets a default description for meta tags. + + :param image: The default image used for social media cards. Can be the + name of an image in the assets folder or an image URL. + :param assets_url_path: The local urls for assets will be: ``requests_pathname_prefix + assets_url_path + '/' + asset_path`` where ``asset_path`` is the path to a file inside ``assets_folder``. @@ -400,15 +414,6 @@ class Dash(ObsoleteChecker): be called after the Flask server is attached. :type plugins: list of objects - :param title: Default ``Dash``. Configures the document.title - (the text that appears in a browser tab). - - :param update_title: Default ``Updating...``. Configures the document.title - (the text that appears in a browser tab) text when a callback is being run. - Set to None or '' if you don't want the document.title to change or if you - want to control the document.title through a separate component or - clientside callback. - :param background_callback_manager: Background callback manager instance to support the ``@callback(..., background=True)`` decorator. One of ``DiskcacheManager`` or ``CeleryManager`` currently supported. @@ -426,8 +431,6 @@ class Dash(ObsoleteChecker): `routing_callback_inputs={"language": Input("language", "value")}` NOTE: the keys "pathname_" and "search_" are reserved for internal use. - :param description: Sets a default description for meta tags on Dash pages (use_pages=True). - :param on_error: Global callback error handler to call when an exception is raised. Receives the exception object as first argument. The callback_context can be used to access the original callback inputs, @@ -493,6 +496,10 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches eager_loading: bool = False, include_assets_files: bool = True, include_pages_meta: bool = True, + title: str = "Dash", + update_title: str = "Updating...", + description: Optional[str] = None, + image: Optional[str] = None, url_base_pathname: Optional[str] = None, requests_pathname_prefix: Optional[str] = None, routes_pathname_prefix: Optional[str] = None, @@ -507,15 +514,12 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches show_undo_redo: bool = False, extra_hot_reload_paths: Optional[Sequence[str]] = None, plugins: Optional[list] = None, - title: str = "Dash", - update_title: str = "Updating...", background_callback_manager: Optional[ Any ] = None, # Type should be specified if possible add_log_handler: bool = True, hooks: Optional[RendererHooks] = None, routing_callback_inputs: Optional[Dict[str, Union[Input, State]]] = None, - description: Optional[str] = None, on_error: Optional[Callable[[Exception], Any]] = None, use_async: Optional[bool] = None, health_endpoint: Optional[str] = None, @@ -613,6 +617,7 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches update_title=update_title, include_pages_meta=include_pages_meta, description=description, + image=image, health_endpoint=health_endpoint, hide_all_callbacks=False, csrf_token_name=csrf_token_name, @@ -646,6 +651,8 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches # keep title as a class property for backwards compatibility self.title = title + self.description = description + self.image = image # MCP (Model Context Protocol) configuration self._enable_mcp = get_combined_config("mcp_enabled", enable_mcp, False) @@ -1496,7 +1503,8 @@ def index(self, *_args, **_kwargs): # Refactored: direct access to global request adapter request = self.backend.request_adapter() - if self.use_pages and self.config.include_pages_meta and request: + # include social media meta tags + if self.config.include_pages_meta and request: metas = _page_meta_tags(self, request) + metas if self._favicon: diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index cbe4181ca5..f49223e457 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -44,6 +44,58 @@ def test_inin003_wildcard_data_attributes(dash_duo): assert dash_duo.get_logs() == [] +def test_inin005_meta_tags_social_media(dash_duo): + app = Dash( + title="my title", + description="my description", + image="my_app.png", + ) + app.layout = html.Div(["Hey! Meta tags!"]) + + dash_duo.start_server(app) + + assert ( + dash_duo.find_element('meta[name="description"]').get_attribute("content") + == "my description" + ) + + assert ( + dash_duo.find_element('meta[property="twitter:title"]').get_attribute("content") + == "my title" + ) + + assert ( + dash_duo.find_element('meta[property="twitter:description"]').get_attribute( + "content" + ) + == "my description" + ) + + assert ( + dash_duo.find_element('meta[property="twitter:image"]') + .get_attribute("content") + .endswith("/assets/my_app.png") + ) + + assert ( + dash_duo.find_element('meta[property="og:title"]').get_attribute("content") + == "my title" + ) + + assert ( + dash_duo.find_element('meta[property="og:description"]').get_attribute( + "content" + ) + == "my description" + ) + + assert ( + dash_duo.find_element('meta[property="og:image"]') + .get_attribute("content") + .endswith("/assets/my_app.png") + ) + + def test_inin006_meta_tags(dash_duo): metas = [ {"name": "description", "content": "my dash app"},