├── .coveragerc ├── .github └── workflows │ ├── build.yml │ ├── docs-publish.yml │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── changelog.md └── index.md ├── mkdocs.yml ├── mkdocsmerge ├── __init__.py ├── __main__.py ├── merge.py └── tests │ ├── __init__.py │ ├── copy_tree_test.py │ ├── merge_test.py │ ├── run_merge_test.py │ └── utils.py ├── pyproject.toml ├── setup.cfg └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = */tests/* -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: MkDocs Merge Validation Build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths-ignore: 7 | - '**.md' 8 | pull_request: 9 | paths-ignore: 10 | - '**.md' 11 | workflow_dispatch: 12 | 13 | jobs: 14 | run-tests: 15 | name: Tests ${{ matrix.python }} on ${{ matrix.os }} 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | os: [macos-latest, windows-latest, ubuntu-latest] 21 | python: ["3.8", "3.9", "3.10"] 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Setup Python 25 | uses: actions/setup-python@v4 26 | with: 27 | python-version: ${{ matrix.python }} 28 | - name: Install tox and any other packages 29 | run: pip install tox pytest pytest-cov click mkdocs ruamel.yaml 30 | - name: Run tox 31 | # Run tox using the version of Python in `PATH` 32 | run: tox -e py 33 | -------------------------------------------------------------------------------- /.github/workflows/docs-publish.yml: -------------------------------------------------------------------------------- 1 | # File: .github/workflows/docs-publish.yml 2 | name: Publish MkDocs-Merge Documentation 3 | 4 | on: 5 | workflow_dispatch: 6 | push: 7 | paths: 8 | - 'docs/**' 9 | - 'mkdocs.yml' 10 | 11 | # Grant permissions needed by the Pages actions 12 | permissions: 13 | contents: read # for checkout 14 | pages: write # to publish 15 | id-token: write # for authentication with deploy-pages 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | # 1) Check out the repo 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | # 2) Set up Python 26 | - name: Set up Python 3.x 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: '3.x' 30 | 31 | # 3) Install MkDocs + MkDocs Material 32 | - name: Install dependencies 33 | run: | 34 | python -m pip install --upgrade pip 35 | pip install mkdocs mkdocs-material 36 | 37 | # 4) Build the site into a folder named `site/` 38 | - name: Build MkDocs site 39 | run: mkdocs build --site-dir site 40 | 41 | # 5) Upload the generated site to GitHub Pages 42 | - name: Upload artifact for GitHub Pages 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: site/ 46 | 47 | # Deployment job 48 | deploy: 49 | environment: 50 | name: github-pages 51 | url: ${{ steps.deployment.outputs.page_url }} 52 | runs-on: ubuntu-latest 53 | needs: build 54 | steps: 55 | - name: Deploy to GitHub Pages 56 | id: deployment 57 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # coverage 104 | .coverage 105 | coverage.xml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Oscar Vasquez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MkDocs Merge 2 | 3 | This simple tool allows you to merge the source of multiple [MkDocs](http://www.mkdocs.org/) sites 4 | into a single one converting each of the specified sites to a sub-site of the master site. 5 | 6 | Supports unification of sites with the same `site_name` into a single sub-site. 7 | 8 | ## Changelog 9 | Access the changelog here: https://ovasquez.github.io/mkdocs-merge/changelog/ 10 | 11 | > Note: Since version 0.6 MkDocs Merge added support for MkDocs 1.0 and dropped 12 | > support for earlier versions. 13 | > See here for more details about the changes in [MkDocs 1.0](https://www.mkdocs.org/about/release-notes/#version-10-2018-08-03). 14 | 15 | --- 16 | [![PyPI version](https://img.shields.io/pypi/v/mkdocs-merge.svg)](https://pypi.python.org/pypi/mkdocs-merge) 17 | [![MkDocs Merge Validation Build](https://github.com/ovasquez/mkdocs-merge/actions/workflows/build.yml/badge.svg)](https://github.com/ovasquez/mkdocs-merge/actions/workflows/build.yml) 18 | 19 | MkDocs-Merge officially supports Python versions 3.8, 3.9 and 3.10. It has been tested to work correctly in previous 3.X versions, but those are no longer officially supported. 20 | 21 | ## Install 22 | 23 | ```bash 24 | $ pip install mkdocs-merge 25 | ``` 26 | 27 | ## Usage 28 | 29 | ```bash 30 | $ mkdocs-merge run MASTER_SITE SITES [-u]... 31 | ``` 32 | 33 | ### Parameters 34 | 35 | - `MASTER_SITE`: the path to the MkDocs site where the base `mkdocs.yml` file resides. This is where all other sites 36 | will be merged into. 37 | - `SITES`: the paths to each of the MkDocs sites that will be merged. Each of these paths is expected to have a 38 | `mkdocs.yml` file and a `docs` folder. 39 | - `-u` (optional): Unify sites with the same "site_name" into a single sub-site. 40 | 41 | ### Example 42 | 43 | ```bash 44 | $ mkdocs-merge run root/mypath/mysite /another/path/new-site /newpath/website 45 | ``` 46 | 47 | A single MkDocs site will be created in `root/mypath/mysite`, and the sites in 48 | `/another/path/new-site` and `/newpath/website` will be added as sub-pages. 49 | 50 | **Original `root/mypath/mysite/mkdocs.yml`** 51 | 52 | ```yaml 53 | ... 54 | nav: 55 | - Home: index.md 56 | - About: about.md 57 | ``` 58 | 59 | **Merged `root/mypath/mysite/mkdocs.yml`** 60 | 61 | ```yaml 62 | ... 63 | nav: 64 | - Home: index.md 65 | - About: about.md 66 | - new-site: new-site/home/another.md # Page merged from /another/path/new-site 67 | - website: website/index.md # Page merged from /newpath/website 68 | ``` 69 | 70 | ## Development 71 | 72 | ### Dev Install 73 | 74 | Clone the repository and specify the `dev` dependencies on the install command. 75 | Project has been updated to use `pyproject.toml` so the version has to be manually synchronized in both `__init__.py` and `pyproject.toml`. 76 | 77 | #### Setup Virtual Environment 78 | 79 | Before installing the package, create and activate a virtual environment in the root directory of the repo: 80 | 81 | ```bash 82 | cd 83 | python -m venv .venv 84 | source .venv/bin/activate 85 | ``` 86 | 87 | #### Install the package for development mode 88 | 89 | ```bash 90 | # Using quotes for zsh compatibility 91 | $ pip install -e '.[dev]' 92 | ``` 93 | 94 | ### Test 95 | 96 | The tests can be run using `tox` from the root directory. `tox` is part of the development dependencies: 97 | 98 | ```bash 99 | $ tox 100 | ``` 101 | 102 | ### Publishing 103 | 104 | The publishing process was updated to use GitHub Actions. 105 | 106 | ## Project Status 107 | 108 | Very basic implementation. The code works but doesn't allow to specify options for the merging. 109 | 110 | ### Pending work 111 | 112 | - [ ] Refactoring of large functions. 113 | - [x] GitHub Actions build. 114 | - [x] Publish pip package. 115 | - [ ] Better error handling. 116 | - [x] Merge configuration via CLI options. 117 | - [x] Unit testing (work in progress). 118 | - [ ] CLI integration testing. 119 | - [ ] Consider more complex cases. 120 | - [x] Make MkDocs Merge module friendly: thanks to [mihaipopescu](https://github.com/mihaipopescu) 121 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.10.0 - May 17, 2025 4 | - Permanent fix for [#20](https://github.com/ovasquez/mkdocs-merge/issues/20): replaced `dir_util.copy_tree` with `shutil.copytree` to use a built-in and maintained API in the directory merge functionality. 5 | - Added a test to verify the scenario of deleting paths and merging them again when using mkdocs-merge as a module. 6 | 7 | ## 0.9.0 - July 30, 2024 8 | - Fixed bug of `dir_util.copy_tree` caused by setuptools moving to 70.2.0 (fixes [#20](https://github.com/ovasquez/mkdocs-merge/issues/20)). 9 | - Updated dependency on deprecated distutils package to use setuptools version. 10 | - Updated project to use `pyproject.toml` instead of `setup.py` (package version now has to be manually kept in sync). 11 | 12 | ## 0.8.0 - January 20, 2023 13 | - Added support for section index pages 14 | [feature from MkDocs Material](https://squidfunk.github.io/mkdocs-material/setup/setting-up-navigation/#section-index-pages) 15 | (thanks to [@Acerinth](https://github.com/Acerinth)). 16 | 17 | ## 0.7.0 - November 16, 2022 18 | - **Breaking change:** removed support for Python 2 and Python 3.4. 19 | - Updated several dependencies. 20 | - DEV: migrated tests from nose to pytest. 21 | 22 | ## 0.6.0 - August 29, 2018 23 | - **Breaking change:** added support for added support for MkDocs 1.0 and dropped support for earlier versions. 24 | 25 | ## 0.5.0 - June 1, 2018 26 | - Fixed the merge process ignoring the `docs` folder in the `mkdocs.yml` of the 27 | sites to merge. 28 | - Removed support for Python 3.3 due to pip removing support for it. 29 | 30 | ## 0.4.2 - February 14, 2018 31 | - Fixed import error in `merge.py` when used in Windows. 32 | 33 | ## 0.4.1 - February 14, 2018 34 | - Fixed import error when used from CLI. 35 | 36 | ## 0.4.0 - February 2, 2018 37 | - Separate CLI functionality from the Merge logic for a more module friendly package. 38 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # MkDocs Merge 2 | 3 | This simple tool allows you to merge the source of multiple [MkDocs](http://www.mkdocs.org/) sites 4 | into a single one converting each of the specified sites to a sub-site of the master site. 5 | 6 | Supports unification of sites with the same `site_name` into a single sub-site. 7 | 8 | > Note: Since version 0.6 MkDocs Merge added support for MkDocs 1.0 and dropped 9 | > support for earlier versions. 10 | > See here for more details about the changes in [MkDocs 1.0](https://www.mkdocs.org/about/release-notes/#version-10-2018-08-03). 11 | 12 | --- 13 | [![PyPI version](https://img.shields.io/pypi/v/mkdocs-merge.svg)](https://pypi.python.org/pypi/mkdocs-merge) 14 | [![MkDocs Merge Validation Build](https://github.com/ovasquez/mkdocs-merge/actions/workflows/build.yml/badge.svg)](https://github.com/ovasquez/mkdocs-merge/actions/workflows/build.yml) 15 | 16 | MkDocs-Merge supports Python versions 3.5+ and pypy. 17 | 18 | ## Install 19 | 20 | ```bash 21 | $ pip install mkdocs-merge 22 | ``` 23 | 24 | ## Usage 25 | 26 | ```bash 27 | $ mkdocs-merge run MASTER_SITE SITES [-u]... 28 | ``` 29 | 30 | ### Parameters 31 | 32 | - `MASTER_SITE`: the path to the MkDocs site where the base `mkdocs.yml` file resides. This is where all other sites 33 | will be merged into. 34 | - `SITES`: the paths to each of the MkDocs sites that will be merged. Each of these paths is expected to have a 35 | `mkdocs.yml` file and a `docs` folder. 36 | - `-u` (optional): Unify sites with the same "site_name" into a single sub-site. 37 | 38 | ### Example 39 | 40 | ```bash 41 | $ mkdocs-merge run root/mypath/mysite /another/path/new-site /newpath/website 42 | ``` 43 | 44 | A single MkDocs site will be created in `root/mypath/mysite`, and the sites in 45 | `/another/path/new-site` and `/newpath/website` will be added as sub-pages. 46 | 47 | **Original `root/mypath/mysite/mkdocs.yml`** 48 | 49 | ```yaml 50 | ... 51 | nav: 52 | - Home: index.md 53 | - About: about.md 54 | ``` 55 | 56 | **Merged `root/mypath/mysite/mkdocs.yml`** 57 | 58 | ```yaml 59 | ... 60 | nav: 61 | - Home: index.md 62 | - About: about.md 63 | - new-site: new-site/home/another.md # Page merged from /another/path/new-site 64 | - website: website/index.md # Page merged from /newpath/website 65 | ``` 66 | 67 | ## Development 68 | 69 | ### Dev Install 70 | 71 | Clone the repository and specify the `dev` dependencies on the install command. 72 | Project has been updated to use `pyproject.toml` so the version has to be manually synchronized. 73 | 74 | #### Setup Virtual Environment 75 | 76 | Before installing the package, create and activate a virtual environment in the root directory of the repo: 77 | 78 | ```bash 79 | cd 80 | python -m venv .venv 81 | source .venv/bin/activate 82 | ``` 83 | 84 | #### Install the package for development mode 85 | 86 | ```bash 87 | # Using quotes for zsh compatibility 88 | $ pip install -e '.[dev]' 89 | ``` 90 | 91 | ### Test 92 | 93 | The tests can be run using `tox` from the root directory. `tox` is part of the development dependencies: 94 | 95 | ```bash 96 | $ tox 97 | ``` 98 | 99 | ### Publishing 100 | 101 | The publishing process was updated to use GitHub Actions. 102 | 103 | ## Project Status 104 | 105 | Very basic implementation. The code works but doesn't allow to specify options for the merging. 106 | 107 | ### Pending work 108 | 109 | - [ ] Refactoring of large functions. 110 | - [x] GitHub Actions build. 111 | - [x] GitHub Actions release automation. 112 | - [x] Publish pip package. 113 | - [ ] Better error handling. 114 | - [x] Merge configuration via CLI options. 115 | - [x] Unit testing (work in progress). 116 | - [ ] CLI integration testing. 117 | - [ ] Consider more complex cases. 118 | - [x] Make MkDocs Merge module friendly: thanks to [mihaipopescu](https://github.com/mihaipopescu) 119 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: MkDocs Merge 2 | site_description: MkDocs site merge tool 3 | site_author: Oscar Vasquez 4 | site_url: https://ovasquez.github.io/mkdocs-merge/ 5 | 6 | copyright: 'Copyright © Oscar Vasquez' 7 | 8 | repo_name: 'ovasquez/mkdocs-merge' 9 | repo_url: 'https://github.com/ovasquez/mkdocs-merge' 10 | 11 | 12 | nav: 13 | - Home: index.md 14 | - Changelog: changelog.md 15 | 16 | theme: 17 | name: material 18 | palette: 19 | # Palette toggle for automatic mode 20 | - media: "(prefers-color-scheme)" 21 | toggle: 22 | icon: material/brightness-auto 23 | name: Switch to light mode 24 | 25 | # Palette toggle for light mode 26 | - media: "(prefers-color-scheme: light)" 27 | scheme: default 28 | primary: indigo 29 | accent: pink 30 | toggle: 31 | icon: material/brightness-7 32 | name: Switch to dark mode 33 | 34 | # Palette toggle for dark mode 35 | - media: "(prefers-color-scheme: dark)" 36 | scheme: slate 37 | primary: indigo 38 | accent: pink 39 | toggle: 40 | icon: material/brightness-4 41 | name: Switch to system preference 42 | icon: 43 | logo: material/source-repository-multiple 44 | 45 | plugins: 46 | - search: 47 | lang: en 48 | 49 | markdown_extensions: 50 | - pymdownx.tasklist: 51 | custom_checkbox: true -------------------------------------------------------------------------------- /mkdocsmerge/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """Init module of MkDocs Merge""" 3 | 4 | 5 | __version__ = "0.10.0" 6 | -------------------------------------------------------------------------------- /mkdocsmerge/__main__.py: -------------------------------------------------------------------------------- 1 | """MkDocs Merge module.""" 2 | 3 | import click 4 | from mkdocsmerge import __version__ 5 | from mkdocsmerge import merge 6 | 7 | UNIFY_HELP = ('Unify sites with the same "site_name" into a single sub-site. Contents of unified ' 8 | 'sub-sites will be stored in the same subsite folder.') 9 | 10 | 11 | @click.group(context_settings={'help_option_names': ['-h', '--help']}) 12 | @click.version_option(__version__, '-V', '--version') 13 | def cli(): 14 | """ 15 | MkDocs-Merge 16 | 17 | This simple tool allows you to merge the sources of multiple MkDocs sites 18 | into a single one, converting each of the specified sites to a subpage of 19 | the master site. 20 | 21 | Basic usage: mkdocs-merge run 22 | 23 | MASTER_SITE: Path to the base site in which all other sites will be merged 24 | into. The mkdocs.yml file of this site will be preserved as is, except for 25 | the new pages. 26 | 27 | SITES: Paths to the sites to be merged. Each of this sites will be 28 | converted to a subpage of the master site. Their mkdocs.yml files 29 | will be ignored except for the pages data. 30 | """ 31 | 32 | 33 | @cli.command() 34 | @click.argument('master-site', type=click.Path()) 35 | @click.argument('sites', type=click.Path(), nargs=-1) 36 | @click.option('-u', '--unify-sites', is_flag=True, help=UNIFY_HELP) 37 | def run(master_site, sites, unify_sites): 38 | """ 39 | Executes the site merging.\n 40 | MASTER_SITE: base site of the merge.\n 41 | SITES: sites to merge into the base site. 42 | """ 43 | 44 | merge.run_merge(master_site, sites, unify_sites, print_func=click.echo) 45 | -------------------------------------------------------------------------------- /mkdocsmerge/merge.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import shutil 3 | from ruamel.yaml import YAML 4 | 5 | 6 | MKDOCS_YML = "mkdocs.yml" 7 | CONFIG_NAVIGATION = "nav" 8 | 9 | 10 | def run_merge(master_site, sites, unify_sites, print_func): 11 | 12 | # Custom argument validation instead of an ugly generic error 13 | if not sites: 14 | print_func( 15 | "Please specify one or more sites to merge to the master " 16 | 'site.\nUse "mkdocs-merge run -h" for more information.' 17 | ) 18 | return 19 | 20 | # Read the mkdocs.yml from the master site 21 | master_yaml = os.path.join(master_site, MKDOCS_YML) 22 | if not os.path.isfile(master_yaml): 23 | print_func( 24 | "Could not find the master site yml file, " 25 | "make sure it exists: " + master_yaml 26 | ) 27 | return None 28 | 29 | # Round-trip yaml loader to preserve formatting and comments 30 | yaml = YAML() 31 | with open(master_yaml) as master_file: 32 | master_data = yaml.load(master_file) 33 | 34 | master_docs_dir = master_data.get("docs_dir", "docs") 35 | master_docs_root = os.path.join(master_site, master_docs_dir) 36 | 37 | # Get all site's navigation pages and copy their files 38 | new_navs = merge_sites(sites, master_docs_root, unify_sites, print_func) 39 | 40 | # then add them to the master nav section 41 | master_data[CONFIG_NAVIGATION] += new_navs 42 | 43 | # Rewrite the master's mkdocs.yml 44 | with open(master_yaml, "w") as master_file: 45 | yaml.dump(master_data, master_file) 46 | 47 | return master_data 48 | 49 | 50 | def merge_sites(sites, master_docs_root, unify_sites, print_func): 51 | """ 52 | Copies the sites content to the master_docs_root and returns 53 | the new merged "nav" pages to be added to the master yaml. 54 | """ 55 | 56 | new_navs = [] 57 | for site in sites: 58 | print_func("\nAttempting to merge site: " + site) 59 | site_yaml = os.path.join(site, MKDOCS_YML) 60 | if not os.path.isfile(site_yaml): 61 | print_func( 62 | "Could not find the site yaml file, this site will be " 63 | 'skipped: "' + site_yaml + '"' 64 | ) 65 | continue 66 | 67 | with open(site_yaml) as site_file: 68 | try: 69 | yaml = YAML(typ="safe") 70 | site_data = yaml.load(site_file) 71 | except Exception: 72 | print_func( 73 | 'Error loading the yaml file "' + site_yaml + '". ' 74 | "This site will be skipped." 75 | ) 76 | continue 77 | 78 | # Check 'site_data' has the 'nav' mapping 79 | if CONFIG_NAVIGATION not in site_data: 80 | print_func( 81 | 'Could not find the "nav" entry in the yaml file: "' 82 | + site_yaml 83 | + '", this site will be skipped.' 84 | ) 85 | if "pages" in site_data: 86 | raise ValueError( 87 | "The site " 88 | + site_yaml 89 | + ' has the "pages" setting in the YAML file which is not ' 90 | "supported since MkDocs 1.0 and is not supported anymore by MkDocs Merge. Please " 91 | "update your site to MkDocs 1.0 or higher." 92 | ) 93 | 94 | try: 95 | site_name = str(site_data["site_name"]) 96 | except Exception: 97 | site_name = os.path.basename(site) 98 | print_func( 99 | 'Could not find the "site_name" property in the yaml file. ' 100 | 'Defaulting the site folder name to: "' + site_name + '"' 101 | ) 102 | 103 | site_root = site_name.replace(" ", "_").lower() 104 | site_docs_dir = site_data.get("docs_dir", "docs") 105 | 106 | # Copy site's files into the master site's "docs" directory 107 | old_site_docs = os.path.join(site, site_docs_dir) 108 | new_site_docs = os.path.join(master_docs_root, site_root) 109 | 110 | if not os.path.isdir(old_site_docs): 111 | print_func( 112 | 'Could not find the site "docs_dir" folder. This site will ' 113 | "be skipped: " + old_site_docs 114 | ) 115 | continue 116 | 117 | try: 118 | # Update if the directory already exists to allow site unification 119 | shutil.copytree(old_site_docs, new_site_docs, dirs_exist_ok=True) 120 | except OSError as exc: 121 | print_func( 122 | 'Error copying files of site "' 123 | + site_name 124 | + '". This site will be skipped.' 125 | ) 126 | print_func(exc.strerror) 127 | continue 128 | 129 | # Update the nav data with the new path after files have been copied 130 | update_navs(site_data[CONFIG_NAVIGATION], site_root, print_func=print_func) 131 | merge_single_site( 132 | new_navs, site_name, site_data[CONFIG_NAVIGATION], unify_sites 133 | ) 134 | 135 | # Inform the user 136 | print_func( 137 | 'Successfully merged site located in "' 138 | + site 139 | + '" as sub-site "' 140 | + site_name 141 | + '"\n' 142 | ) 143 | 144 | return new_navs 145 | 146 | 147 | def merge_single_site(global_nav, site_name, site_nav, unify_sites): 148 | """ 149 | Merges a single site's nav to the global nav's data. Supports unification 150 | of sub-sites with the same site_name. 151 | """ 152 | unified = False 153 | if unify_sites: 154 | # Check if the site_name already exists in the global_nav 155 | for page in global_nav: 156 | if site_name in page: 157 | # Combine the new site's pages to the existing entry 158 | page[site_name] = page[site_name] + site_nav 159 | unified = True 160 | break 161 | # Append to the global list if no unification was requested or it didn't exist. 162 | if (not unify_sites) or (not unified): 163 | global_nav.append({site_name: site_nav}) 164 | 165 | 166 | def update_navs(navs, site_root, print_func): 167 | """ 168 | Recursively traverses the lists of navs (dictionaries) to update the path 169 | of the navs with the site_name, used as a subsection in the merged site. 170 | """ 171 | if isinstance(navs, list): 172 | for page in navs: 173 | if isinstance(page, str): 174 | navs[navs.index(page)] = site_root + "/" + page 175 | else: 176 | update_navs(page, site_root, print_func) 177 | elif isinstance(navs, dict): 178 | for name, path in navs.items(): 179 | if isinstance(path, str): 180 | navs[name] = site_root + "/" + path 181 | elif isinstance(path, list): 182 | update_navs(navs[name], site_root, print_func) 183 | else: 184 | print_func('Error merging the "nav" entry in the site: ' + site_root) 185 | else: 186 | print_func('Error merging the "nav" entry in the site: ' + site_root) 187 | -------------------------------------------------------------------------------- /mkdocsmerge/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ovasquez/mkdocs-merge/e78e33219368f9ed404dff53ec33167fd26f3abf/mkdocsmerge/tests/__init__.py -------------------------------------------------------------------------------- /mkdocsmerge/tests/copy_tree_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import shutil 4 | import tempfile 5 | import unittest 6 | 7 | from mkdocsmerge.merge import merge_sites 8 | 9 | 10 | class TestCopyTreeWhenModuleImportMerge(unittest.TestCase): 11 | """ 12 | This test is needed because there was a bug where subsequent calls to distutil.copy_tree 13 | would fail when mkdocs-merge was used as a module (https://stackoverflow.com/a/28055993/920464) 14 | Even though the code was updated to use shutil.copytree, this test remains valuable to prevent 15 | a similar bug from happening again. 16 | """ 17 | 18 | def setUp(self): 19 | # Create an isolated temp directory and cd into it 20 | self.tmpdir = tempfile.mkdtemp() 21 | self.old_cwd = os.getcwd() 22 | os.chdir(self.tmpdir) 23 | 24 | # --- Set up a fake siteA with mkdocs.yml + docs/index.md --- 25 | os.makedirs("siteA/docs", exist_ok=True) 26 | with open("siteA/mkdocs.yml", "w") as f: 27 | f.write("site_name: siteA\n" "nav:\n" " - Home: index.md\n") 28 | with open("siteA/docs/index.md", "w") as f: 29 | f.write("# Hello from siteA\n") 30 | 31 | # --- Prepare the master/docs folder --- 32 | os.makedirs("master/docs", exist_ok=True) 33 | 34 | def tearDown(self): 35 | # Cleanup 36 | os.chdir(self.old_cwd) 37 | shutil.rmtree(self.tmpdir) 38 | 39 | def test_merge_twice_with_deletion(self): 40 | master_docs = os.path.join("master", "docs") 41 | # First merge should always succeed 42 | merge_sites(["siteA"], master_docs, unify_sites=False, print_func=print) 43 | first_copy = os.path.join(master_docs, "sitea", "index.md") 44 | self.assertTrue( 45 | os.path.isfile(first_copy), 46 | f"After first merge, expected {first_copy} to exist", 47 | ) 48 | 49 | # Remove the merged folder to trigger the old distutils cache bug 50 | shutil.rmtree(os.path.join(master_docs, "sitea")) 51 | 52 | # Second merge should *not* raise and should recreate the files 53 | # This failed with dir_util but succeeded with shutil 54 | merge_sites(["siteA"], master_docs, unify_sites=False, print_func=print) 55 | second_copy = os.path.join(master_docs, "sitea", "index.md") 56 | self.assertTrue( 57 | os.path.isfile(second_copy), 58 | f"After second merge, expected {second_copy} to exist", 59 | ) 60 | 61 | 62 | if __name__ == "__main__": 63 | unittest.main() 64 | -------------------------------------------------------------------------------- /mkdocsmerge/tests/merge_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the MkDocs Merge package 3 | """ 4 | 5 | import unittest 6 | import mkdocsmerge.merge 7 | 8 | 9 | class TestSiteMerges(unittest.TestCase): 10 | """ 11 | Test class for MkDocs Merge. Will be separated when necessary. 12 | """ 13 | 14 | def setUp(self): 15 | print('Test: ' + self._testMethodName) 16 | 17 | def test_update_pages(self): 18 | """ 19 | Verifies the function that updates the path to the pages adding the another 20 | new subroute at the begining of each page's path 21 | """ 22 | # Create original and expected data structures 23 | subpage = 'new_root' 24 | subpage_path = subpage + '/' 25 | nav = [{'Home': 'index.md'}, 26 | {'About': 'menu/about.md'}, 27 | {'Projects': [ 28 | {'First': 'projects/first.md'}, 29 | {'Nested': [ 30 | {'Third': 'projects/nest/third.md'} 31 | ]}, 32 | {'Second': 'projects/second.md'} 33 | ]}] 34 | 35 | expected = [{'Home': subpage_path + 'index.md'}, 36 | {'About': subpage_path + 'menu/about.md'}, 37 | {'Projects': [ 38 | {'First': subpage_path + 'projects/first.md'}, 39 | {'Nested': [ 40 | {'Third': subpage_path + 'projects/nest/third.md'} 41 | ]}, 42 | {'Second': subpage_path + 'projects/second.md'} 43 | ]}] 44 | 45 | mkdocsmerge.merge.update_navs(nav, subpage, lambda x: None) 46 | self.assertEqual(nav, expected) 47 | 48 | def test_singe_site_merge(self): 49 | """ 50 | Verifies merging of a single site's nav to the global nav's data without unification. 51 | """ 52 | site_name = 'Projects' 53 | # Create original and expected data structures 54 | global_nav = [{'Home': 'index.md'}, 55 | {'About': 'menu/about.md'}, 56 | {site_name: [ 57 | {'First': 'projects/first.md'}, 58 | {'Second': 'projects/second.md'} 59 | ]}] 60 | 61 | site_nav = [{'Nested': [ 62 | {'Third': 'projects/nest/third.md'}, 63 | {'Fourth': 'projects/nest/fourth.md'} 64 | ]}] 65 | 66 | expected = [{'Home': 'index.md'}, 67 | {'About': 'menu/about.md'}, 68 | {site_name: [ 69 | {'First': 'projects/first.md'}, 70 | {'Second': 'projects/second.md'}, 71 | ]}, 72 | {site_name: [ 73 | {'Nested': [ 74 | {'Third': 'projects/nest/third.md'}, 75 | {'Fourth': 'projects/nest/fourth.md'} 76 | ]}, 77 | ]}] 78 | 79 | mkdocsmerge.merge.merge_single_site( 80 | global_nav, site_name, site_nav, False) 81 | self.assertEqual(global_nav, expected) 82 | 83 | def test_singe_site_merge_unified(self): 84 | """ 85 | Verifies merging of a single site's nav to the global nav's data with unification 86 | of the sub-sites with the same site_name 87 | """ 88 | site_name = 'Projects' 89 | # Create original and expected data structures 90 | global_nav = [{'Home': 'index.md'}, 91 | {'About': 'menu/about.md'}, 92 | {site_name: [ 93 | {'First': 'projects/first.md'}, 94 | {'Second': 'projects/second.md'} 95 | ]}] 96 | 97 | site_nav = [{'Nested': [ 98 | {'Third': 'projects/nest/third.md'}, 99 | {'Fourth': 'projects/nest/fourth.md'} 100 | ]}] 101 | 102 | expected = [{'Home': 'index.md'}, 103 | {'About': 'menu/about.md'}, 104 | {site_name: [ 105 | {'First': 'projects/first.md'}, 106 | {'Second': 'projects/second.md'}, 107 | {'Nested': [ 108 | {'Third': 'projects/nest/third.md'}, 109 | {'Fourth': 'projects/nest/fourth.md'} 110 | ]}, 111 | ]}] 112 | 113 | mkdocsmerge.merge.merge_single_site( 114 | global_nav, site_name, site_nav, True) 115 | self.assertEqual(global_nav, expected) 116 | 117 | def test_update_pages_with_section_indexes(self): 118 | """ 119 | Verifies the correct updating of the section index pages' paths. 120 | """ 121 | # Create original and expected data structures 122 | subpage = 'new_root' 123 | subpage_path = subpage + '/' 124 | nav = [{'Home': 'index.md'}, 125 | {'Projects': [ 126 | 'projects/index.md', 127 | {'Nested': [ 128 | 'projects/nested/index.md', 129 | {'Third': 'projects/nest/third.md'} 130 | ]} 131 | ]}] 132 | 133 | expected = [{'Home': subpage_path + 'index.md'}, 134 | {'Projects': [ 135 | subpage_path + 'projects/index.md', 136 | {'Nested': [ 137 | subpage_path + 'projects/nested/index.md', 138 | {'Third': subpage_path + 'projects/nest/third.md'} 139 | ]} 140 | ]}] 141 | 142 | mkdocsmerge.merge.update_navs(nav, subpage, lambda x: None) 143 | self.assertEqual(nav, expected) 144 | -------------------------------------------------------------------------------- /mkdocsmerge/tests/run_merge_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tempfile 4 | import unittest 5 | 6 | import mkdocsmerge.merge 7 | 8 | from .utils import generate_website, make_simple_yaml 9 | 10 | 11 | class TestRunMerge(unittest.TestCase): 12 | 13 | def setUp(self): 14 | self.tmpdir = tempfile.mkdtemp() 15 | self.owd = os.getcwd() 16 | os.chdir(self.tmpdir) 17 | 18 | def test_run_merge(self): 19 | 20 | docs_dir_map = { 21 | '__master__': 'master_docs', 22 | 'Test': 'test_docs' 23 | } 24 | 25 | site_names = ['__master__', 'Foo', 'Bar', 'Test'] 26 | for site_name in site_names: 27 | docs_dir = docs_dir_map.get(site_name, None) 28 | yml = make_simple_yaml(site_name, docs_dir) 29 | generate_website(self.tmpdir, site_name, yml) 30 | 31 | merged_pages = mkdocsmerge.merge.run_merge( 32 | site_names[0], site_names[1:], True, lambda x: None) 33 | 34 | for site_name in site_names[1:]: 35 | index_path = os.path.join( 36 | site_name, docs_dir_map.get(site_name, 'docs'), 'index.md') 37 | self.assertTrue(os.path.exists(index_path)) 38 | 39 | docs_dir_path = os.path.join( 40 | site_names[0], docs_dir_map.get(site_names[0], 'docs'), 41 | '%s_website' % site_name.lower()) 42 | self.assertTrue(os.path.exists(docs_dir_path)) 43 | 44 | self.assertEqual(merged_pages, { 45 | 'site_name': '__master__ Website', 46 | 'nav': [ 47 | {'Home': "index.md"}, 48 | {'Foo Website': [ 49 | {'Home': 'foo_website/index.md'} 50 | ]}, 51 | {'Bar Website': [ 52 | {'Home': 'bar_website/index.md'} 53 | ]}, 54 | {'Test Website': [ 55 | {'Home': 'test_website/index.md'} 56 | ]} 57 | ], 58 | 'docs_dir': 'master_docs' 59 | }) 60 | 61 | def tearDown(self): 62 | # Avoid leaving the temp directory open until program exit (bug in Windows) 63 | os.chdir(self.owd) 64 | shutil.rmtree(self.tmpdir) 65 | -------------------------------------------------------------------------------- /mkdocsmerge/tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from ruamel.yaml import YAML 3 | 4 | 5 | def generate_website(directory, name, yml=None): 6 | root = os.path.join(directory, name) 7 | os.mkdir(root) 8 | 9 | if yml is None: 10 | yml = make_simple_yaml(name) 11 | 12 | yaml = YAML() 13 | with open(os.path.join(root, 'mkdocs.yml'), 'w') as f: 14 | yaml.dump(yml, f) 15 | 16 | docs_folder = yml.get('docs_dir', 'docs') 17 | 18 | docs_dir = os.path.join(root, docs_folder) 19 | os.mkdir(docs_dir) 20 | generate_dummy_pages(docs_dir, 'nav', yml['nav']) 21 | 22 | 23 | def make_simple_yaml(name, docs_dir=None): 24 | yml = { 25 | 'site_name': '%s Website' % name, 26 | 'nav': [ 27 | {'Home': "index.md"}, 28 | ] 29 | } 30 | 31 | if docs_dir: 32 | yml['docs_dir'] = docs_dir 33 | return yml 34 | 35 | 36 | def generate_dummy_pages(docs_dir, key, node): 37 | if isinstance(node, list): 38 | for item in node: 39 | generate_dummy_pages(docs_dir, key, item) 40 | elif isinstance(node, dict): 41 | for k, v in node.items(): 42 | generate_dummy_pages(docs_dir, k, v) 43 | else: 44 | path = os.path.join(docs_dir, str.replace(node, '\\', '/')) 45 | if not os.path.exists(os.path.dirname(path)): 46 | os.makedirs(os.path.dirname(path)) 47 | with open(path, 'w') as mdf: 48 | mdf.write(""" 49 | * %s Title 50 | Contents 51 | """ % key) 52 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "mkdocs-merge" 7 | version = "0.10.0" 8 | description = "Tool to merge multiple MkDocs sites into a single directory" 9 | readme = "README.md" 10 | license = { text = "MIT" } 11 | authors = [ 12 | { name = "Oscar Vasquez", email = "oscar@vasquezcr.com" } 13 | ] 14 | keywords = ["mkdocs", "documentation", "merge", "multiple"] 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Topic :: Documentation", 18 | "Topic :: Text Processing", 19 | "Environment :: Console", 20 | "Environment :: Web Environment", 21 | "License :: OSI Approved :: MIT License", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10" 26 | ] 27 | dependencies = [ 28 | "click>=5.0", 29 | "mkdocs>=1.0", 30 | "ruamel.yaml>=0.17" 31 | ] 32 | 33 | [project.urls] 34 | homepage = "https://github.com/ovasquez/mkdocs-merge" 35 | repository = "https://github.com/ovasquez/mkdocs-merge" 36 | download = "https://github.com/ovasquez/mkdocs-merge/archive/main.zip" 37 | 38 | [project.optional-dependencies] 39 | dev = [ 40 | "tox>=3.0", 41 | "pytest", 42 | "pytest-cov" 43 | ] 44 | 45 | [project.scripts] 46 | mkdocs-merge = "mkdocsmerge.__main__:cli" -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | description-file = README.md 6 | license_file = LICENSE -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py{38,39,310,py}, flake8 8 | 9 | [testenv] 10 | commands = 11 | py{,38,39,310,py}: pytest --cov=mkdocsmerge --cov-append --cov-report=term-missing 12 | deps = 13 | pytest 14 | pytest-cov 15 | 16 | [testenv:flake8] 17 | basepython = python 18 | deps = 19 | flake8 20 | commands = flake8 mkdocsmerge --max-line-length=119 21 | --------------------------------------------------------------------------------