Repository: mkdocs/mkdocs
Pull request #486 (2015-04-28 02:35:00)

Preserve order of Python Markdown extensions
Python Markdown inclusion order can be important when including various custom extensions.  Conflicts can arise as to where extensions get inserted in the workflow.  By preserving order of how extensions are
defined in the settings file, this can allow a user to control insertion order and work around these issues.  As Py26 supported is noted to be removed in the future, I saw no reason to add additional dependencies for OrderedDict.  Try catch statements can be removed when Py26 support is removed.


Patch:
diff --git a/mkdocs/build.py b/mkdocs/build.py
index abfbb877..e0120ee9 100644
--- a/mkdocs/build.py
+++ b/mkdocs/build.py
@@ -38,7 +38,7 @@ def convert_markdown(markdown_source, site_navigation=None, extensions=(), stric
         extension_configs = {}
     builtin_extensions = ['meta', 'toc', 'tables', 'fenced_code']
     mkdocs_extensions = [RelativePathExtension(site_navigation, strict), ]
-    extensions = set(builtin_extensions + mkdocs_extensions + user_extensions)
+    extensions = utils.reduce_list(builtin_extensions + mkdocs_extensions + user_extensions)
     md = markdown.Markdown(
         extensions=extensions,
         extension_configs=extension_configs
diff --git a/mkdocs/config.py b/mkdocs/config.py
index 06e91db2..122bc591 100644
--- a/mkdocs/config.py
+++ b/mkdocs/config.py
@@ -5,7 +5,6 @@ import logging
 import ntpath
 import os
 
-import yaml
 from six.moves.urllib.parse import urlparse
 
 from mkdocs import utils
@@ -81,7 +80,7 @@ def load_config(filename='mkdocs.yml', options=None):
     if not os.path.exists(filename):
         raise ConfigurationError("Config file '%s' does not exist." % filename)
     with open(filename, 'r', encoding='utf-8') as fp:
-        user_config = yaml.load(fp)
+        user_config = utils.yaml_load(fp)
         if not isinstance(user_config, dict):
             raise ConfigurationError("The mkdocs.yml file is invalid. See http://www.mkdocs.org/user-guide/configuration/ for more information.")
     user_config.update(options)
diff --git a/mkdocs/utils.py b/mkdocs/utils.py
index 690bf659..a35d0da5 100644
--- a/mkdocs/utils.py
+++ b/mkdocs/utils.py
@@ -12,6 +12,37 @@ import shutil
 
 from six.moves.urllib.parse import urlparse
 from six.moves.urllib.request import pathname2url
+try:
+    from collections import OrderedDict
+    import yaml
+
+    def yaml_load(stream, Loader=yaml.Loader, object_pairs_hook=OrderedDict):
+        """
+        Make all YAML dictionaries load as ordered Dicts.
+        http://stackoverflow.com/a/21912744/3609487
+        """
+        class OrderedLoader(Loader):
+            pass
+
+        def construct_mapping(loader, node):
+            loader.flatten_mapping(node)
+            return object_pairs_hook(loader.construct_pairs(node))
+
+        OrderedLoader.add_constructor(
+            yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
+            construct_mapping
+        )
+
+        return yaml.load(stream, OrderedLoader)
+except ImportError:
+    # Can be removed when Py26 support is removed
+    from yaml import load as yaml_load  # noqa
+
+
+def reduce_list(data_set):
+    """ Reduce duplicate items in a list and preserve order """
+    seen = set()
+    return [item for item in data_set if item not in seen and not seen.add(item)]
 
 
 def copy_file(source_path, output_path):


Test patch:
diff --git a/mkdocs/tests/utils_tests.py b/mkdocs/tests/utils_tests.py
index 53804272..7ab5974a 100644
--- a/mkdocs/tests/utils_tests.py
+++ b/mkdocs/tests/utils_tests.py
@@ -4,6 +4,7 @@
 import unittest
 
 from mkdocs import nav, utils
+from mkdocs.tests.base import dedent
 
 
 class UtilsTests(unittest.TestCase):
@@ -67,3 +68,34 @@ class UtilsTests(unittest.TestCase):
         for path, expected_result in expected_results.items():
             urls = utils.create_media_urls(site_navigation, [path])
             self.assertEqual(urls[0], expected_result)
+
+    def test_yaml_load(self):
+        try:
+            from collections import OrderedDict
+        except ImportError:
+            # Don't test if can't import OrderdDict
+            # Exception can be removed when Py26 support is removed
+            return
+
+        yaml_text = dedent('''
+            test:
+                key1: 1
+                key2: 2
+                key3: 3
+                key4: 4
+                key5: 5
+                key5: 6
+                key3: 7
+        ''')
+
+        self.assertEqual(
+            utils.yaml_load(yaml_text),
+            OrderedDict([('test', OrderedDict([('key1', 1), ('key2', 2), ('key3', 7),
+                                               ('key4', 4), ('key5', 6)]))])
+        )
+
+    def test_reduce_list(self):
+        self.assertEqual(
+            utils.reduce_list([1, 2, 3, 4, 5, 5, 2, 4, 6, 7, 8]),
+            [1, 2, 3, 4, 5, 6, 7, 8]
+        )