Repository: mkdocs/mkdocs

Issue:
Start the default browser, when starting the `mkdocs serve` command?
See discussion in #3494 

It would be good to open the page in the default browser, when typing `mkdocs serve` (as happens when running the `jupyter notebook` command.



### Various points
1. This should be done once, not every time a change is detected.
2. Make it work by command-line option and config file : opt-in (initially) and then opt-out (if successful).
3. I have experimented with code, and one of the problems I met was with async: the time it might take for the page to load on the browser, versus the time it takes for the server to start.

Discussion:

Patch:
diff --git a/mkdocs/__main__.py b/mkdocs/__main__.py
index 0df2069b..41377cf5 100644
--- a/mkdocs/__main__.py
+++ b/mkdocs/__main__.py
@@ -110,6 +110,7 @@ config_help = (
     "Provide a specific MkDocs config. This can be a file name, or '-' to read from stdin."
 )
 dev_addr_help = "IP address and port to serve documentation locally (default: localhost:8000)"
+serve_open_help = "Open the website in a Web browser after the initial build finishes."
 strict_help = "Enable strict mode. This will cause MkDocs to abort the build on any warnings."
 theme_help = "The theme to use when building your documentation."
 theme_choices = sorted(utils.get_theme_names())
@@ -249,6 +250,7 @@ def cli():
 
 @cli.command(name="serve")
 @click.option('-a', '--dev-addr', help=dev_addr_help, metavar='<IP:PORT>')
+@click.option('-o', '--open', 'open_in_browser', help=serve_open_help, is_flag=True)
 @click.option('--no-livereload', 'livereload', flag_value=False, help=no_reload_help)
 @click.option('--livereload', 'livereload', flag_value=True, default=True, hidden=True)
 @click.option('--dirtyreload', 'build_type', flag_value='dirty', hidden=True)
diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py
index 4a21a3da..2c9e9793 100644
--- a/mkdocs/commands/build.py
+++ b/mkdocs/commands/build.py
@@ -22,8 +22,6 @@ from mkdocs.utils import templates
 if TYPE_CHECKING:
     from mkdocs.config.defaults import MkDocsConfig
 
-if TYPE_CHECKING:
-    from mkdocs.livereload import LiveReloadServer
 
 log = logging.getLogger(__name__)
 
@@ -247,9 +245,7 @@ def _build_page(
         config._current_page = None
 
 
-def build(
-    config: MkDocsConfig, live_server: LiveReloadServer | None = None, dirty: bool = False
-) -> None:
+def build(config: MkDocsConfig, *, serve_url: str | None = None, dirty: bool = False) -> None:
     """Perform a full site build."""
     logger = logging.getLogger('mkdocs')
 
@@ -259,7 +255,7 @@ def build(
     if config.strict:
         logging.getLogger('mkdocs').addHandler(warning_counter)
 
-    inclusion = InclusionLevel.all if live_server else InclusionLevel.is_included
+    inclusion = InclusionLevel.all if serve_url else InclusionLevel.is_included
 
     try:
         start = time.monotonic()
@@ -280,7 +276,7 @@ def build(
                 " links within your site. This option is designed for site development purposes only."
             )
 
-        if not live_server:  # pragma: no cover
+        if not serve_url:  # pragma: no cover
             log.info(f"Building documentation to directory: {config.site_dir}")
             if dirty and site_directory_contains_stale_files(config.site_dir):
                 log.info("The directory contains stale files. Use --clean to remove them.")
@@ -306,8 +302,8 @@ def build(
         for file in files.documentation_pages(inclusion=inclusion):
             log.debug(f"Reading: {file.src_uri}")
             if file.page is None and file.inclusion.is_not_in_nav():
-                if live_server and file.inclusion.is_excluded():
-                    excluded.append(urljoin(live_server.url, file.url))
+                if serve_url and file.inclusion.is_excluded():
+                    excluded.append(urljoin(serve_url, file.url))
                 Page(None, file, config)
             assert file.page is not None
             _populate_page(file.page, config, files, dirty)
diff --git a/mkdocs/commands/serve.py b/mkdocs/commands/serve.py
index dc472a66..889d8066 100644
--- a/mkdocs/commands/serve.py
+++ b/mkdocs/commands/serve.py
@@ -9,7 +9,7 @@ from urllib.parse import urlsplit
 
 from mkdocs.commands.build import build
 from mkdocs.config import load_config
-from mkdocs.livereload import LiveReloadServer
+from mkdocs.livereload import LiveReloadServer, _serve_url
 
 if TYPE_CHECKING:
     from mkdocs.config.defaults import MkDocsConfig
@@ -23,6 +23,8 @@ def serve(
     build_type: str | None = None,
     watch_theme: bool = False,
     watch: list[str] = [],
+    *,
+    open_in_browser: bool = False,
     **kwargs,
 ) -> None:
     """
@@ -37,9 +39,6 @@ def serve(
     # string is returned. And it makes MkDocs temp dirs easier to identify.
     site_dir = tempfile.mkdtemp(prefix='mkdocs_')
 
-    def mount_path(config: MkDocsConfig):
-        return urlsplit(config.site_url or '/').path
-
     def get_config():
         config = load_config(
             config_file=config_file,
@@ -47,7 +46,6 @@ def serve(
             **kwargs,
         )
         config.watch.extend(watch)
-        config.site_url = f'http://{config.dev_addr}{mount_path(config)}'
         return config
 
     is_clean = build_type == 'clean'
@@ -56,16 +54,20 @@ def serve(
     config = get_config()
     config.plugins.on_startup(command=('build' if is_clean else 'serve'), dirty=is_dirty)
 
+    host, port = config.dev_addr
+    mount_path = urlsplit(config.site_url or '/').path
+    config.site_url = serve_url = _serve_url(host, port, mount_path)
+
     def builder(config: MkDocsConfig | None = None):
         log.info("Building documentation...")
         if config is None:
             config = get_config()
+            config.site_url = serve_url
 
-        build(config, live_server=None if is_clean else server, dirty=is_dirty)
+        build(config, serve_url=None if is_clean else serve_url, dirty=is_dirty)
 
-    host, port = config.dev_addr
     server = LiveReloadServer(
-        builder=builder, host=host, port=port, root=site_dir, mount_path=mount_path(config)
+        builder=builder, host=host, port=port, root=site_dir, mount_path=mount_path
     )
 
     def error_handler(code) -> bytes | None:
@@ -99,7 +101,7 @@ def serve(
                 server.watch(item)
 
         try:
-            server.serve()
+            server.serve(open_in_browser=open_in_browser)
         except KeyboardInterrupt:
             log.info("Shutting down...")
         finally:
diff --git a/mkdocs/livereload/__init__.py b/mkdocs/livereload/__init__.py
index f98da658..70d44ba2 100644
--- a/mkdocs/livereload/__init__.py
+++ b/mkdocs/livereload/__init__.py
@@ -18,6 +18,7 @@ import threading
 import time
 import traceback
 import urllib.parse
+import webbrowser
 import wsgiref.simple_server
 import wsgiref.util
 from typing import Any, BinaryIO, Callable, Iterable
@@ -81,6 +82,15 @@ class _LoggerAdapter(logging.LoggerAdapter):
 log = _LoggerAdapter(logging.getLogger(__name__), {})
 
 
+def _normalize_mount_path(mount_path: str) -> str:
+    """Ensure the mount path starts and ends with a slash."""
+    return ("/" + mount_path.lstrip("/")).rstrip("/") + "/"
+
+
+def _serve_url(host: str, port: int, path: str) -> str:
+    return f"http://{host}:{port}{_normalize_mount_path(path)}"
+
+
 class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGIServer):
     daemon_threads = True
     poll_response_timeout = 60
@@ -96,16 +106,14 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe
         shutdown_delay: float = 0.25,
     ) -> None:
         self.builder = builder
-        self.server_name = host
-        self.server_port = port
         try:
             if isinstance(ipaddress.ip_address(host), ipaddress.IPv6Address):
                 self.address_family = socket.AF_INET6
         except Exception:
             pass
         self.root = os.path.abspath(root)
-        self.mount_path = ("/" + mount_path.lstrip("/")).rstrip("/") + "/"
-        self.url = f"http://{self.server_name}:{self.server_port}{self.mount_path}"
+        self.mount_path = _normalize_mount_path(mount_path)
+        self.url = _serve_url(host, port, mount_path)
         self.build_delay = 0.1
         self.shutdown_delay = shutdown_delay
         # To allow custom error pages.
@@ -161,7 +169,7 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe
             self._watched_paths.pop(path)
             self.observer.unschedule(self._watch_refs.pop(path))
 
-    def serve(self):
+    def serve(self, *, open_in_browser=False):
         self.server_bind()
         self.server_activate()
 
@@ -171,8 +179,13 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe
             paths_str = ", ".join(f"'{_try_relativize_path(path)}'" for path in self._watched_paths)
             log.info(f"Watching paths for changes: {paths_str}")
 
-        log.info(f"Serving on {self.url}")
+        if open_in_browser:
+            log.info(f"Serving on {self.url} and opening it in a browser")
+        else:
+            log.info(f"Serving on {self.url}")
         self.serve_thread.start()
+        if open_in_browser:
+            webbrowser.open(self.url)
 
         self._build_loop()
 


Test patch:
diff --git a/mkdocs/tests/build_tests.py b/mkdocs/tests/build_tests.py
index 4b7596bf..95145298 100644
--- a/mkdocs/tests/build_tests.py
+++ b/mkdocs/tests/build_tests.py
@@ -16,7 +16,6 @@ import markdown.preprocessors
 from mkdocs.commands import build
 from mkdocs.config import base
 from mkdocs.exceptions import PluginError
-from mkdocs.livereload import LiveReloadServer
 from mkdocs.structure.files import File, Files
 from mkdocs.structure.nav import get_navigation
 from mkdocs.structure.pages import Page
@@ -36,13 +35,6 @@ def build_page(title, path, config, md_src=''):
     return page, files
 
 
-def testing_server(root, builder=lambda: None, mount_path="/"):
-    with mock.patch("socket.socket"):
-        return LiveReloadServer(
-            builder, host="localhost", port=123, root=root, mount_path=mount_path
-        )
-
-
 class BuildTests(PathAssertionMixin, unittest.TestCase):
     def _get_env_with_null_translations(self, config):
         env = config.theme.get_env()
@@ -596,7 +588,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
             exclude_docs='ba*.md',
         )
 
-        with self.subTest(live_server=None):
+        with self.subTest(serve_url=None):
             expected_logs = '''
                 INFO:Doc file 'test/foo.md' contains a link to 'test/bar.md' which is excluded from the built site.
             '''
@@ -606,8 +598,8 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
             self.assertPathNotExists(site_dir, 'test', 'baz.html')
             self.assertPathNotExists(site_dir, '.zoo.html')
 
-        server = testing_server(site_dir, mount_path='/documentation/')
-        with self.subTest(live_server=server):
+        serve_url = 'http://localhost:123/documentation/'
+        with self.subTest(serve_url=serve_url):
             expected_logs = '''
                 INFO:Doc file 'test/bar.md' contains a relative link 'nonexistent.md', but the target 'test/nonexistent.md' is not found among documentation files.
                 INFO:Doc file 'test/foo.md' contains a link to 'test/bar.md' which is excluded from the built site.
@@ -617,7 +609,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
                   - http://localhost:123/documentation/test/baz.html
             '''
             with self._assert_build_logs(expected_logs):
-                build.build(cfg, live_server=server)
+                build.build(cfg, serve_url=serve_url)
 
             foo_path = Path(site_dir, 'test', 'foo.html')
             self.assertTrue(foo_path.is_file())
@@ -639,13 +631,13 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
     def test_conflicting_readme_and_index(self, site_dir, docs_dir):
         cfg = load_config(docs_dir=docs_dir, site_dir=site_dir, use_directory_urls=False)
 
-        for server in None, testing_server(site_dir):
-            with self.subTest(live_server=server):
+        for serve_url in None, 'http://localhost:123/':
+            with self.subTest(serve_url=serve_url):
                 expected_logs = '''
                     WARNING:Excluding 'foo/README.md' from the site because it conflicts with 'foo/index.md'.
                 '''
                 with self._assert_build_logs(expected_logs):
-                    build.build(cfg, live_server=server)
+                    build.build(cfg, serve_url=serve_url)
 
                 index_path = Path(site_dir, 'foo', 'index.html')
                 self.assertPathIsFile(index_path)
@@ -663,10 +655,10 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
             docs_dir=docs_dir, site_dir=site_dir, use_directory_urls=False, exclude_docs='index.md'
         )
 
-        for server in None, testing_server(site_dir):
-            with self.subTest(live_server=server):
+        for serve_url in None, 'http://localhost:123/':
+            with self.subTest(serve_url=serve_url):
                 with self._assert_build_logs(''):
-                    build.build(cfg, live_server=server)
+                    build.build(cfg, serve_url=serve_url)
 
                 index_path = Path(site_dir, 'foo', 'index.html')
                 self.assertPathIsFile(index_path)
@@ -693,9 +685,9 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
             assert f is not None
             config.nav = Path(f.abs_src_path).read_text().splitlines()
 
-        for server in None, testing_server(site_dir):
+        for serve_url in None, 'http://localhost:123/':
             for exclude in 'full', 'nav', None:
-                with self.subTest(live_server=server, exclude=exclude):
+                with self.subTest(serve_url=serve_url, exclude=exclude):
                     cfg = load_config(
                         docs_dir=docs_dir,
                         site_dir=site_dir,
@@ -711,13 +703,13 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
                             INFO:The following pages exist in the docs directory, but are not included in the "nav" configuration:
                               - SUMMARY.md
                         '''
-                    if exclude == 'full' and server:
+                    if exclude == 'full' and serve_url:
                         expected_logs = '''
                             INFO:The following pages are being built only for the preview but will be excluded from `mkdocs build` per `exclude_docs`:
                               - http://localhost:123/SUMMARY.html
                         '''
                     with self._assert_build_logs(expected_logs):
-                        build.build(cfg, live_server=server)
+                        build.build(cfg, serve_url=serve_url)
 
                     foo_path = Path(site_dir, 'foo.html')
                     self.assertPathIsFile(foo_path)
@@ -727,7 +719,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
                     )
 
                     summary_path = Path(site_dir, 'SUMMARY.html')
-                    if exclude == 'full' and not server:
+                    if exclude == 'full' and not serve_url:
                         self.assertPathNotExists(summary_path)
                     else:
                         self.assertPathExists(summary_path)
diff --git a/mkdocs/tests/cli_tests.py b/mkdocs/tests/cli_tests.py
index 6ce03db1..abd1286e 100644
--- a/mkdocs/tests/cli_tests.py
+++ b/mkdocs/tests/cli_tests.py
@@ -21,6 +21,7 @@ class CLITests(unittest.TestCase):
         self.assertEqual(result.exit_code, 0)
         mock_serve.assert_called_once_with(
             dev_addr=None,
+            open_in_browser=False,
             livereload=True,
             build_type=None,
             config_file=None,
@@ -53,6 +54,7 @@ class CLITests(unittest.TestCase):
         self.assertEqual(result.exit_code, 0)
         mock_serve.assert_called_once_with(
             dev_addr='0.0.0.0:80',
+            open_in_browser=False,
             livereload=True,
             build_type=None,
             config_file=None,
@@ -70,6 +72,7 @@ class CLITests(unittest.TestCase):
         self.assertEqual(result.exit_code, 0)
         mock_serve.assert_called_once_with(
             dev_addr=None,
+            open_in_browser=False,
             livereload=True,
             build_type=None,
             config_file=None,
@@ -89,6 +92,7 @@ class CLITests(unittest.TestCase):
         self.assertEqual(result.exit_code, 0)
         mock_serve.assert_called_once_with(
             dev_addr=None,
+            open_in_browser=False,
             livereload=True,
             build_type=None,
             config_file=None,
@@ -108,6 +112,7 @@ class CLITests(unittest.TestCase):
         self.assertEqual(result.exit_code, 0)
         mock_serve.assert_called_once_with(
             dev_addr=None,
+            open_in_browser=False,
             livereload=True,
             build_type=None,
             config_file=None,
@@ -127,6 +132,7 @@ class CLITests(unittest.TestCase):
         self.assertEqual(result.exit_code, 0)
         mock_serve.assert_called_once_with(
             dev_addr=None,
+            open_in_browser=False,
             livereload=True,
             build_type=None,
             config_file=None,
@@ -144,6 +150,7 @@ class CLITests(unittest.TestCase):
         self.assertEqual(result.exit_code, 0)
         mock_serve.assert_called_once_with(
             dev_addr=None,
+            open_in_browser=False,
             livereload=True,
             build_type=None,
             config_file=None,
@@ -161,6 +168,7 @@ class CLITests(unittest.TestCase):
         self.assertEqual(result.exit_code, 0)
         mock_serve.assert_called_once_with(
             dev_addr=None,
+            open_in_browser=False,
             livereload=False,
             build_type=None,
             config_file=None,
@@ -178,6 +186,7 @@ class CLITests(unittest.TestCase):
         self.assertEqual(result.exit_code, 0)
         mock_serve.assert_called_once_with(
             dev_addr=None,
+            open_in_browser=False,
             livereload=True,
             build_type='dirty',
             config_file=None,
@@ -195,6 +204,7 @@ class CLITests(unittest.TestCase):
         self.assertEqual(result.exit_code, 0)
         mock_serve.assert_called_once_with(
             dev_addr=None,
+            open_in_browser=False,
             livereload=True,
             build_type=None,
             config_file=None,
diff --git a/mkdocs/tests/livereload_tests.py b/mkdocs/tests/livereload_tests.py
index 6a7a0c9f..b19009a3 100644
--- a/mkdocs/tests/livereload_tests.py
+++ b/mkdocs/tests/livereload_tests.py
@@ -39,6 +39,8 @@ def testing_server(root, builder=lambda: None, mount_path="/"):
             mount_path=mount_path,
             polling_interval=0.2,
         )
+        server.server_name = "localhost"
+        server.server_port = 0
         server.setup_environ()
     server.observer.start()
     thread = threading.Thread(target=server._build_loop, daemon=True)