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:
I'll implement this :+1: 
See also @pawamoy's comment: 

> If it can detect that the URL is already open in a tab, then that would be best. 
OK let's say the URL is already open in a tab, what should it do then? Just not open the browser? I think that's worse than opening the browser even if the tab will be duplicated. Unfortunately there's no implementation for "focus an existing tab if possible".
It makes a sense: a duplicated tab should not be a big deal.
> 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.

Hmm for me it worked reliably. Could you try my pull request and see if you run into such issues with it?
> I think that's worse than opening the browser even if the tab will be duplicated.

Yeah that's what other tools do anyway. We can discard my comment about detecting the tab.

Resolving patch:
diff --git a/mkdocs/__main__.py b/mkdocs/__main__.py
--- a/mkdocs/__main__.py
+++ b/mkdocs/__main__.py
@@ -110,6 +110,7 @@ def __del__(self):
     "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/serve.py b/mkdocs/commands/serve.py
--- a/mkdocs/commands/serve.py
+++ b/mkdocs/commands/serve.py
@@ -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:
     """
@@ -99,7 +101,7 @@ def error_handler(code) -> bytes | None:
                 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
--- a/mkdocs/livereload/__init__.py
+++ b/mkdocs/livereload/__init__.py
@@ -18,6 +18,7 @@
 import time
 import traceback
 import urllib.parse
+import webbrowser
 import wsgiref.simple_server
 import wsgiref.util
 from typing import Any, BinaryIO, Callable, Iterable
@@ -161,7 +162,7 @@ def unwatch(self, path: str) -> None:
             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 +172,13 @@ def serve(self):
             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/cli_tests.py b/mkdocs/tests/cli_tests.py
--- a/mkdocs/tests/cli_tests.py
+++ b/mkdocs/tests/cli_tests.py
@@ -21,6 +21,7 @@ def test_serve_default(self, mock_serve):
         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 @@ def test_serve_dev_addr(self, mock_serve):
         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 @@ def test_serve_strict(self, mock_serve):
         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 @@ def test_serve_theme(self, mock_serve):
         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 @@ def test_serve_use_directory_urls(self, mock_serve):
         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 @@ def test_serve_no_directory_urls(self, mock_serve):
         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 @@ def test_serve_livereload(self, mock_serve):
         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 @@ def test_serve_no_livereload(self, mock_serve):
         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 @@ def test_serve_dirtyreload(self, mock_serve):
         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 @@ def test_serve_watch_theme(self, mock_serve):
         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,