Repository: valentingol/cliconfig (language: python)

Issue:
:memo: Major documentation rework



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/pdoc.yaml b/.github/workflows/pdoc.yaml
new file mode 100644
index 0000000..c383631
--- /dev/null
+++ b/.github/workflows/pdoc.yaml
@@ -0,0 +1,49 @@
+name: Documentation
+
+# build the documentation whenever there are new commits on main
+on:
+  push:
+    branches:
+      - main
+      - pdoc
+      - pdoc_dev
+    # Alternative: only build for tags.
+    # tags:
+    #   - '*'
+
+# security: restrict permissions for CI jobs.
+permissions:
+  contents: read
+
+jobs:
+  # Build the documentation and upload the static HTML files as an artifact.
+  build:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-python@v4
+        with:
+          python-version: "3.11"
+
+      - run: pip install -e .
+      - run: pip install -r docs/requirements.txt
+      - run: pdoc -d numpy -t docs/template --logo https://raw.githubusercontent.com/valentingol/cliconfig/main/docs/_static/logo.png --no-include-undocumented -o docs/build --math --search cliconfig
+
+      - uses: actions/upload-pages-artifact@v1
+        with:
+          path: docs/build
+
+  # Deploy the artifact to GitHub pages.
+  # This is a separate job so that only actions/deploy-pages has the necessary permissions.
+  deploy:
+    needs: build
+    runs-on: ubuntu-latest
+    permissions:
+      pages: write
+      id-token: write
+    environment:
+      name: github-pages
+      url: ${{ steps.deployment.outputs.page_url }}
+    steps:
+      - id: deployment
+        uses: actions/deploy-pages@v1
diff --git a/.gitignore b/.gitignore
index 6ff627e..4cc749d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,4 +23,5 @@ run.py
 cliconfig/_version.py
 # Docs
 docs/_build
+docs/build
 build
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
deleted file mode 100644
index f81cee2..0000000
--- a/.readthedocs.yaml
+++ /dev/null
@@ -1,21 +0,0 @@
-# Read the Docs configuration file
-# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
-
-# Required
-version: 2
-
-# Set the version of Python and other tools you might need
-build:
-  os: ubuntu-22.04
-  tools:
-    python: "3.11"
-
-# Build documentation in the docs/ directory with Sphinx
-sphinx:
-  configuration: docs/conf.py
-
-# We recommend specifying your dependencies to enable reproducible builds:
-# https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
-python:
-  install:
-    - requirements: docs/requirements.txt
diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md
new file mode 100644
index 0000000..1d99615
--- /dev/null
+++ b/DOCUMENTATION.md
@@ -0,0 +1,850 @@
+<p align="center">
+  <img src="https://raw.githubusercontent.com/valentingol/cliconfig/main/docs/_static/logo_extend_black.png" />
+</p>
+
+*CLI Config* is a lightweight library that provides routines to merge nested configs
+and set parameters from command line. It contains many routines to create and manipulate
+the config as flatten or nested python dictionaries. It also provides processing functions
+that can change the whole configuration before and after each config manipulation.
+
+The package was initially designed for machine learning experiments where the
+number of parameters is huge and a lot of them have to be set by the user between
+each experiment. If your project matches this description, this package is for you!
+
+[Documentation](https://valentingol.github.io/cliconfig)
+
+[Pypi package](https://pypi.org/project/cliconfig/)
+
+[Source code (Github)](https://github.com/valentingol/cliconfig/)
+
+[![Release](https://img.shields.io/github/v/tag/valentingol/cliconfig?label=Pypi&logo=pypi&logoColor=yellow)](https://pypi.org/project/cliconfig/)
+![PythonVersion](https://img.shields.io/badge/Python-3.7%20%7E%203.11-informational)
+[![License](https://img.shields.io/github/license/valentingol/cliconfig?color=999)](https://stringfixer.com/fr/MIT_license)
+
+[![Ruff_logo](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json)](https://github.com/charliermarsh/ruff)
+[![Black_logo](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
+
+[![Ruff](https://github.com/valentingol/cliconfig/actions/workflows/ruff.yaml/badge.svg)](https://github.com/valentingol/cliconfig/actions/workflows/ruff.yaml)
+[![Flake8](https://github.com/valentingol/cliconfig/actions/workflows/flake.yaml/badge.svg)](https://github.com/valentingol/cliconfig/actions/workflows/flake.yaml)
+[![Pydocstyle](https://github.com/valentingol/cliconfig/actions/workflows/pydocstyle.yaml/badge.svg)](https://github.com/valentingol/cliconfig/actions/workflows/pydocstyle.yaml)
+[![MyPy](https://github.com/valentingol/cliconfig/actions/workflows/mypy.yaml/badge.svg)](https://github.com/valentingol/cliconfig/actions/workflows/mypy.yaml)
+[![PyLint](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/valentingol/ab12676c87f0eaa715bef0f8ad31a604/raw/cliconfig_pylint.json)](https://github.com/valentingol/cliconfig/actions/workflows/pylint.yaml)
+
+[![Tests](https://github.com/valentingol/cliconfig/actions/workflows/tests.yaml/badge.svg)](https://github.com/valentingol/cliconfig/actions/workflows/tests.yaml)
+[![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/valentingol/098e9c7c53be88779ee52ef2f2bc8803/raw/cliconfig_tests.json)](https://github.com/valentingol/cliconfig/actions/workflows/tests.yaml)
+[![Documentation Status](https://github.com/valentingol/cliconfig/actions/workflows/pdoc.yaml/badge.svg)](https://valentingol.github.io/cliconfig)
+
+Official badge :
+[![Config](https://custom-icon-badges.demolab.com/badge/cliconfig-black?logo=cliconfig)](https://github.com/valentingol/cliconfig)
+
+## Installation
+
+In a new virtual environment, simply install the package via
+[pypi](https://pypi.org/project/cliconfig/):
+
+```bash
+pip install cliconfig
+```
+
+This package is OS independent and supported on Linux, macOS and Windows.
+
+## Quick start
+
+Create yaml file(s) that contain your default configuration. All the parameters
+should be listed (see later to organize them simply in case of big config files).
+
+```yaml
+# default1.yaml
+param1: 1
+param2: 1
+letters:
+  letter1: a
+  letter2: b
+
+# default2.yaml
+param1: 1
+param2: 2  # will override param2 from default1.yaml
+letters.letter3: c  # add a new parameter
+```
+
+Get your config in your python code:
+
+```python
+# main.py
+from cliconfig import make_config
+
+config = make_config('default1.yaml', 'default2.yaml')
+```
+
+Add additional config file(s) that represent your experiments. They will override
+the default values.
+
+```yaml
+# first.yaml
+letters:
+  letter3: C  # equivalent to "letters.letter3: 'C'"
+
+# second.yaml
+param1: -1
+letters.letter1: A
+```
+
+Please note that new parameters that are not present in the
+default configs are not allowed. This restriction is in place to prevent potential
+typos in the config files from going unnoticed. It also enhances the readability
+of the default config files and ensures retro-compatibility (see later to circumnavigate
+it for particular cases). This restriction apart, the package allows a complete
+liberty of config manipulation.
+
+Run your code with the additional config files AND eventually some other parameters
+from command line. **Please respect the exact syntax for spaces and equal signs**.
+
+```bash
+python main.py --config first.yaml,second.yaml --param2=-2 --letters.letter2='B'
+```
+
+If you have multiple config files it is possible to pass a list with brackets.
+Be careful, using ``--config=first.yaml`` will NOT be recognized as an additional config
+file (space is important) but as a parameter called "config" with value "first.yaml"
+(it then raises an error if no "config" parameter is on the default config).
+
+Now the config look like this:
+
+```bash
+Config:
+    param1: -1  # overridden by second.yaml
+    param2: -2  # overridden by command line args
+    letters:
+        letter1: A  # overridden by second.yaml
+        letter2: B  # overridden by command line args
+        letter3: C  # overridden by first.yaml
+```
+
+You can also manipulate your config with the following functions:
+
+```python
+from cliconfig import load_config, save_config, show_config, update_config
+show_config(config)  # print config
+config.dict  # config as native dict
+config.dict['letters']['letter1']  # access parameter via dict
+config.letters.letter1  # access parameter via dots
+config.letters.letter1 = 'G'  # modify parameter
+del config.letters.letter1  # delete parameter
+# Update config with a dict or another config
+config = update_config(config, {'letters': {'letter1': 'H'}})
+# Save the config as a yaml file
+save_config(config, 'myconfig.yaml')
+# Load the config and merge with the default configs if provided
+# (useful if default configs were updated)
+config = load_config('myconfig.yaml', default_config_paths=['default1.yaml', 'default2.yaml'])
+```
+
+The config object is just a wrapper around the config dict that allows to access the parameters
+via dots (and containing the list of processings, see the
+[*Processing*](https://valentingol.github.io/cliconfig/cliconfig.html#processing)
+section for details). That's all! Therefore, the config object is
+very light and simple to use.
+
+While the config object is simple, the possibilities to manipulate the config via
+processings are endless. See the next section for some default features. One of the
+core idea of this package is to make it easy to add your own config features for your
+specific needs.
+
+## Use tags
+
+By default, the package provides some "tags" represented as strings that start with
+'@' and are placed at the end of a key containing a parameter. These tags change
+the way the configuration is processed.
+
+The default tags include:
+
+* `@merge_add`, `@merge_before`, and `@merge_after`: These tags merge the dictionary
+  loaded from the specified value (which should be a YAML path) into the current
+  configuration. `@merge_add` allows only the merging of new keys and is useful for
+  splitting non-overlapping sub-configurations into multiple files. `@merge_before` merges
+  the current dictionary onto the loaded one, while `@merge_after` merges the loaded
+  dictionary onto the current one. These tags are used to organize multiple config files.
+* `@copy`: This tag copies a parameter value from another parameter name. The value
+  associated to the parameter with this tag should be a string
+  that represents the flattened key. The copied value is then protected from further
+  updates but will be dynamically updated if the copied key change during a merge.
+* `@def`: This tag evaluate an expression to define the parameter value.
+  The value associated to a parameter tagged with `@def` can contain any
+  parameter name of the configuration. The most useful operators and built-in
+  functions are supported, the `random` and `math` packages are also supported
+  as well as some (safe) `numpy`, `jax`, `tensorflow`, `torch` functions.
+  If/else statements and comprehension lists are also supported.
+* `@type:<my type>`: This tag checks if the key matches the specified type `<my type>`
+  after each update, even if the tag is no longer present. It tries to convert
+  the type if it is not the good one. It supports basic types
+  (except for tuples and sets, which are not handled by YAML) as well as unions
+  (using "Union" or "|"), optional values, nested list, and nested dict.
+  For instance: `my_param@type:List[Dict[str, int|float]]: [{"a": 0}]`.
+* `@select`: This tag select param/sub-config(s) to keep and delete the other
+  param/sub-configs in the same parent config. The tagged key is not deleted if
+  it is in the parent config.
+* `@delete`: This tag deletes the param/sub-config from the config before merging.
+  It is usefull to trigger a processing without keeping the key in the config.
+* `@new`: This tag allows adding new key(s) to the config that are not already
+  present in the default config(s). It can be used for single parameter or a
+  sub-config. Disclaimer: it is preferable to have exhaustive default config(s)
+  instead of abusing this tag for readability and for security concerning typos.
+* `@dict`: This tag allows to have a dictionary object instead of a sub-config
+  where you can modify the keys (see the
+  [*Edge cases*](https://valentingol.github.io/cliconfig/cliconfig.html#edge-cases) section)
+
+The tags are applied in a particular order that ensure no conflict between them.
+
+Please note that the tags serve as triggers for internal processing and will be
+automatically removed from the key before you can use it. The tags are designed to
+give instructions to python without being visible in the config.
+
+It is also possible to combine multiple tags. For example:
+
+```yaml
+# main.yaml
+path_1@merge_add: sub1.yaml
+path_2@merge_add: sub2.yaml
+config3.selection@delete@select: config3.param1
+
+# sub1.yaml
+config1:
+  param@copy@type:int: config2.param
+  param2@type:float: 1  # int: wrong type -> converted to float
+
+# sub2.yaml
+config2.param: 2
+config3:
+  param1@def: "[(config1.param2 + config2.param) / 2] * 2 if config2.param else None"
+  param2: 3
+my_dict@dict:
+  key1: 1
+  key2: 2
+```
+
+Note that can also use YAML tags separated with "@" (like `key: !tag@tag2 value`)
+to add tags instead of putting them in the parameter name (like `key@tag@tag2: value`).
+
+Here `main.yaml` is interpreted like:
+
+```yaml
+path_1: sub1.yaml
+path_2: sub2.yaml
+config1:
+  param: 2  # the value of config2.param
+  param2: 1.0  # converted to float
+config2:
+  param: 2
+config3:
+  param1: [1.5, 1.5]
+  # param2 is deleted because it is not in the selection
+my_dict: {key1: 1, key2: 2}  # (changing the whole dict further is allowed)
+```
+
+Then, all the parameters in `config1` have enforced types, changing
+`config2.param` will also update `config1.param` accordingly (which is
+protected by direct update). Finally, changing `config1.param2` or `config2.param`
+will update `config3.param1` accordingly until a new value is set for `config3.param1`.
+
+These side effects are not visible in the config but stored on processing objects.
+They are objects that find the tags, remove them from config and apply a modification.
+These processing are powerful tools that can be used to highly customize the
+configuration at each step of the process.
+
+You can easily create your own processing (associated to a tag or not).
+The way to do it and a further explanation of them is available in the
+[*Processing*](https://valentingol.github.io/cliconfig/cliconfig.html#processing)
+section of the documentation.
+
+## Edge cases
+
+* **Please note that YAML does not support tuples and sets**, and therefore they
+  cannot be used in YAML files. If possible, consider using lists instead.
+
+* Moreover, YAML does not recognize "None" as a None object, but interprets it as a
+  string. If you wish to set a None object, you can use "null" or "Null" instead.
+
+* "@" is a special character used by the package to identify tags. You can't use it
+  in your parameters names (but you can use it in your values). It will raise an error
+  if you try to do so.
+
+* "dict" and "process_list" are reserved names of attributes and should not be used
+  as sub-config or parameter names. It can raise an error if you try to access them
+  as config attributes (with dots).
+
+In the context of this package, dictionaries are treated as sub-configurations,
+which means that modifying or adding keys directly in the additional configs may
+not be possible (because only the merge of default configuration allow adding new keys).
+If you need to have a dictionary object where you can modify the keys, consider
+using the `@dict` tag:
+
+For instance:
+
+```yaml
+# default.yaml
+logging:
+  metrics: [train loss, val loss]
+  styles@dict: {train_loss: red, val_loss: blue}
+# additional.yaml
+logging:
+  metrics: [train loss, val acc]
+  styles@dict: {train_loss: red, val_acc: cyan}
+```
+
+Like a sub-config, the dictionary can be accessed with the dot notation like this:
+`config.logging.styles.val_acc` and will return "cyan".
+
+## Processing
+
+Processings are powerful tools to modify the config at each step of the lifecycle of
+a configuration. More precisely, you can use processings to modify the full
+configuration before and after each merge, after loading, and before saving the config.
+
+The processings are applied via a processing object that have five methods
+(called "processing" to simplify): `premerge`, `postmerge`, `endbuild`, `postload`
+and `presave`. These names correspond to the timing they are applied. Each processing
+has the signature:
+
+```python
+def premerge(self, flat_config: Config) -> Config:
+    ...
+    return flat_config
+```
+
+Where `Config` is a simple class containing only two attributes (and no methods):
+`dict` that is the configuration dict and `process_list`, the list of processing objects
+(we discuss this in a section below). Note that it is
+also the class of the object returned by the `make_config` function.
+
+They only take a flat config as input i.e a config containing a dict of depth 1 with
+dot-separated keys and return the modified flat dict (and keep it flat!).
+
+In this section, you will learn how they work and how to create your own to make
+whatever you want with the config.
+
+### Why a flat dict?
+
+The idea is that when we construct a config, we manipulate dictionaries that contain
+both nested sub-dictionaries and flat keys simultaneously. To simplify this process, the
+dictionaries are systematically flattened before merging. This approach makes things
+simpler and prevents duplicated keys within the same configuration, as shown in the
+example:
+
+```python
+config = {'a': {'b': 1}, 'a.b': 2}
+```
+
+More generally, all config modifications are performed using flat dictionaries
+during config construction, and the same applies to processings. For processings,
+it is even more interesting as you can have access to the full sub-config names
+to make your processing if needed.
+
+However, it's important to note that after building your config with `make_config`,
+the dict will be unflattened to its normal nested configuration structure.
+
+### Processing order
+
+The order in which the processings are triggered is crucial because they modify
+the config and consequently affect the behavior of subsequent processings.
+To manage this order, the processing class have five float attributes representing
+the order of the five processing methods: premerge, postmerge, endbuild, postload,
+and presave.
+
+Here's a basic example to illustrate the significance of the order:
+
+```yaml
+# config1.yaml
+merge@merge_add@delete: config2.yaml
+param: 1
+# config2.yaml
+param2: 2
+```
+
+In this example, we want to build a global config using `config1.yaml`. This file contains only
+half of the parameters, and the other half is in `config2.yaml`. Then, we add a key
+with the name of our choice, here "merge", tagged with `@merge_add` to merge
+`config2.yaml` before the global config update. We add the `@delete` tag to delete
+the key "merge" before merging with the global config because in this case, there is
+no key with the name "merge" in the global config, and it would raise an error as
+it is not possible to ass new keys.
+
+`@merge_add` and `@delete` has both only a pre-merge effect. Let's check the orders.
+It is `-20.0` for merge and `30.0` for delete. So merge trigger first, add `param2` and
+the "merge" key is deleted after it. If the orders were reversed, the key would have been
+deleted before merge processing and so the `param2` would not have been updated with the
+value of 2 and the resulting configuration would potentially not have been
+the expected one at all.
+
+Therefore, it is crucial to carefully manage the order when creating your own
+processings!
+
+Some useful ranges to choose your order:
+
+* not important: order = 0 (default)
+* if it checks/modifies the config before applying any processing: order < -25
+* if it adds new parameters: -25 < order < -5
+* if it updates a value based on itself: -5 < order < 5
+* if it updates a value based on other keys: 5 < order < 15
+* if it checks a property on a value: 15 < order < 25
+* if it deletes other key(s) but you want to trigger the tags before: 25 < order < 35
+* final check/modification after all processings: order > 35
+
+Note: a pre-merge order should not be greater than 1000, the order of the default
+processing `ProcessCheckTags` that raise an error if tags still exist at the end
+of the pre-merge step.
+
+### Create basic processing
+
+#### Processing that modify a single value
+
+One of the most useful kind of processing look for parameters which names match a certain
+pattern (e.g a prefix or a suffix) or contain a specific tag and modify their values
+depending on their current ones.
+
+To simplify the creation of such a process, we provide the `cliconfig.create_processing_value` function.
+This function allows you to quickly create a processing that matches a regular
+expression or a specific tag name (in which case the tag is removed after pre-merging).
+You specify the function to be applied on the value to modify it, and optionally,
+the order of the processing. Additionally, there is a `persistent` argument, which is
+a boolean value indicating whether encountering the tag (if a tag is used) once in
+a parameter name will continue to trigger the processing for this parameter
+even after the tag is removed. By default, it is `False`. Finally, you can set
+the processing type (pre-merge, post-merge, etc.) at your convenience. Default is pre-merge.
+
+Here's an example to illustrate:
+
+```python
+proc = create_processing_value(lambda x: str(x), 'premerge', tag_name='convert_str', persistent=True)
+config = make_config(default_config, process_list=[proc])
+```
+
+In this example, the config `{"subconfig.param@convert_str": 1}` will
+be converted to `{"subconfig.param": "1"}`. Moreover, the keys `subconfig.param`
+will be permanently converted to strings before every merge.
+
+It's worth noting that you can also use functions that have side effects without
+necessarily changing the value itself. For example, you can use a function to
+check if a certain condition is met by the value.
+
+It is also possible to pass the flat config as a second argument to the function.
+For example:
+
+```yaml
+# config.yaml
+param: 1
+param2@eval: "config.param + 1"
+```
+
+```python
+proc = create_processing_value(
+    lambda x, config: eval(x, {"config": config}),
+    tag_name="eval",
+    persistent=False,
+)
+# (Note that the `eval` function is not safe and the code above
+# should not be used in case of untrusted config)
+```
+
+Here the value of `param2` will be evaluated to 2 at pre-merge step.
+
+#### Pre-merge/post-merge processing that protect a property from being modified
+
+Another useful kind of processing is a processing that ensure to keep a certain
+property on the value. For this kind of processing, you can use
+`cliconfig.create_processing_keep_property`. It takes a function that returns
+the property from the value, the regex or the tag name like the previous function,
+and the order of the pre-merge and the post-merge.
+
+The pre-merge processing looks for keys that match the tag or the regex, apply
+the function on the value and store the result (= the "property").
+The post-merge and end-build processing will check that the property is the same as
+the one stored during pre-merge. If not, it will raise an error.
+
+Examples:
+
+A processing that enforce the types of all the parameters to be constant
+(equal to the type of the first value encountered):
+
+```python
+create_processing_keep_property(
+    type,
+    regex=".*",
+    premerge_order=15.0,
+    postmerge_order=15.0,
+    endbuild_order=15.0
+)
+```
+
+A processing that protect parameters tagged with @protect from being changed:
+
+```python
+create_processing_keep_property(
+    lambda x: x,
+    tag_name="protect",
+    premerge_order=15.0,
+    postmerge_order=15.0,
+    endbuild_order=15.0
+)
+```
+
+Each time you choose the order `15.0` because it is a good value for processing that
+made checks on the values. Indeed, processings that change the values such as
+`ProcessCopy` have an order that is generally $\leq$ 10.0.
+
+It is also possible to pass the flat config as a second argument to the function
+similarly to `create_processing_value`.
+
+### Create your processing classes (Advanced)
+
+To create your own processing classes and unlock more possibilities, you simply
+need to overload the methods of the Processing class to modify the config at the
+desired timings. To do so, you often need to manipulate tags.
+
+#### Manipulate the tags
+
+Tags are useful for triggering a processing, as we have seen. However, we need
+to be cautious because tagging a key modifies its name and can lead to conflicts
+when using processing. To address this issue, we provide tag routines in
+`cliconfig.tag_routines`. These routines include:
+
+* `is_tag_in`: Checks if a tag is in a key. It looks for the exact tag name.
+  If `full_key` is True, it looks for all the flat key, including sub-configs
+  (default: False)
+* `clean_tag`: Removes a specific tag (based on its exact name) from a key.
+  It is helpful to remove the tag after pre-merging.
+* `clean_all_tags`: Removes all tags from a key. This is helpful each time you
+  need the true parameter name.
+* `clean_dict_tags`: Removes all tags from a dictionary and returns the cleaned
+  dict along with a list of keys that contained tags. This is helpful to get all
+  the parameter names of a full dict with tags.
+
+With these tools, we can write a processing, for example, that searches for all
+parameters with a tag `@look` ant that prints their sorted values at the end of
+the post-merging.
+
+```python
+class ProcessPrintSorted(Processing):
+    """Print the parameters tagged with "@look", sorted by value on post-merge."""
+
+    def __init__(self) -> None:
+        super().__init__()
+        self.looked_keys: Set[str] = set()
+        # Pre-merge just look for the tag so order is not important
+        self.premerge_order = 0.0
+        # Post-merge should be after the copy processing if we want the final values
+        # on post-merge
+        self.postmerge_order = 15.0
+
+    def premerge(self, flat_config: Config) -> Config:
+        """Pre-merge processing."""
+        # Browse a freeze version of the dict (because we will modify it to remove tags)
+        items = list(flat_config.dict.items())
+        for flat_key, value in items:
+            if is_tag_in(flat_key, "look"):  # Check if the key contains the tag
+                # Remove the tag and update the dict
+                new_key = clean_tag(flat_key, "look")
+                flat_config.dict[new_key] = value
+                del flat_config.dict[flat_key]
+                # Store the key
+                clean_key = clean_all_tags(key)  # remove all tags = true parameter name
+                self.looked_keys.add(clean_key)
+        return flat_config
+
+    def postmerge(self, flat_config: Config) -> Config:
+        """Post-merge processing."""
+        values = []
+        for key in self.looked_keys:
+            # IMPORTANT
+            # ("if key in flat_config.dict:" is important in case of some keys were
+            # removed or if multiple dicts with different parameters are seen by
+            # the processing)
+            if key in flat_config.dict:
+                values.append(flat_config.dict[key])
+        print("The sorted looked values are: ", sorted(values))
+        # If we don't want to keep the looked keys for further print:
+        self.looked_keys = set()
+
+        return flat_config
+
+# And to use it:
+config = make_config("main.yaml", process_list=[ProcessPrintSorted()])
+```
+
+**Important note**: After all pre-merge processings, the config should no longer contains
+tags as they should be removed by pre-merge processings. Otherwise, a security
+processing raises an error. It is then not necessary to take care on tags on post-merge,
+and pre-save.
+
+#### Merge, save or load configs in processing
+
+The key concept is that as long as we deal with processings, the elementary operations
+on the config are not actually to merge, save, and load a config, but rather:
+
+* Applying pre-merge processing, then merging, then applying post-merge processing.
+* Applying end-build processing at the end of the config building.
+* Applying pres-ave processing and then saving a config.
+* Loading a config and then applying post-load processing.
+
+These three operations are in `cliconfig.process_routines` and called
+`merge_processing`, `end_build_processing`, `save_processing`, and `load_processing`,
+respectively. They take as input a Config object that contains as we see the list of processing.
+
+Now, the trick is that sometimes we want to apply these operations to the processing
+themselves, particularly when we want to modify a part of the configuration instead
+of just a single parameter (such as merging two configurations). This is why it is
+particularly useful to have access to the full Config object and not only the
+dict.
+
+For example, consider the tag `@merge_add`, which triggers a processing before
+merging and merges the config loaded from a specified path (the value) into the
+current config. We may want to see what happens if we merge a config that also
+contains a `@merge_add` tag within it:
+
+```yaml
+# main.yaml
+config_path1@merge_add: path1.yaml
+# path1.yaml
+param1: 1
+config_path2@merge_add: path2.yaml
+# path2.yaml
+param2: 2
+```
+
+Now, let's consider we want to merge the config `main.yaml` with another config.
+During the pre-merge processing, we encounter the tag `@merge_add`. This tag is
+removed, and the config found at `path1.yaml` will be merged into the `main.yaml`
+config. However before this, it triggers the pre-merging.
+
+Therefore, before the merge `path1.yaml`, the processing discovers the key
+`config_path2@merge_add` and merges the config found at `path2.yaml` into `path1.yaml`.
+Then, `path1.yaml` is merged into `main.yaml`. Finally, the resulting configuration
+can be interpreted as follows:
+
+```python
+{'param1': 1, 'param2': 2, 'config_path1': 'path1.yaml', 'config_path2': 'path2.yaml'}
+```
+
+before being merged itself with another config. Note that is not only a processing that
+allows to organize the configuration on multiple files. In fact, it also allows you for
+instance to choose a particular configuration among several ones by setting the path
+as value of the tagged key (as long as this config is on the default configs).
+
+#### Change processing list in processing (Still more advanced)
+
+Note that the processing functions receive the list of processing objects as an
+input and update as an attribute of the processing object. This means that it
+is possible to manually modify this list in processing functions.
+
+**Warning**: The processing list to apply during pre/post-merge, pre-save and
+post-load are determined before the first processing is applied. Therefore, you can't
+add or remove processing and expect it to be effective during the current merge/save/load.
+However, if you modify their internal variables it will be effective immediately.
+
+Here an example of a processing that remove the type check of a parameter in
+`ProcessTyping` processing. It is then possible for instance to force another
+type (it is not possible otherwise).
+
+```python
+from cliconfig.processing.builtin import ProcessTyping
+
+class ProcessBypassTyping(Processing):
+    """Bypass type check of ProcessTyping for parameters tagged with "@bypass_typing".
+
+    In pre-merge it looks for a parameter with the tag "@bypass_typing",
+    removes it and change the internal ProcessTyping variables to avoid
+    checking the type of the parameter with ProcessTyping.
+    """
+
+    def __init__(self) -> None:
+        super().__init__()
+        self.bypassed_forced_types: Dict[str, tuple] = {}
+        # Before ProcessTyping pre-merge to let it change the type
+        self.premerge_order = 1.0
+
+    def premerge(self, flat_config: Config) -> Config:
+        """Pre-merge processing."""
+        items = list(flat_config.dict.items())
+        for flat_key, value in items:
+            if is_tag_in(flat_key, "bypass_typing"):
+                new_key = clean_tag(flat_key, "bypass_typing")
+                flat_config.dict[new_key] = value
+                del flat_config.dict[flat_key]
+                clean_key = clean_all_tags(flat_key)
+                for processing in flat_config.process_list:
+                    if (isinstance(processing, ProcessTyping)
+                            and clean_key in processing.forced_types):
+                        forced_type = processing.forced_types.pop(clean_key)
+                        self.bypassed_forced_types[clean_key] = forced_type
+        return flat_config
+
+# Without bypass:
+config1 = Config({"a@type:int": 0}, [ProcessBypassTyping(), ProcessTyping()])
+config2 = Config({"a@type:str": "a"}, [])
+config = merge_flat_processing(config1, config2)
+# > Error: try to change the forced type of "a" from int to str
+
+# With bypass:
+config1 = Config({"a@type:int": 0}, [ProcessBypassTyping(), ProcessTyping()])
+config2 = Config({"a@bypass_typing@type:str": "a"}, [])
+config = merge_flat_processing(config1, config2)
+# > No error
+```
+
+## Alternative ways to create a config
+
+### From a python dict
+
+```python
+from cliconfig import Config
+my_dict = {'param1': 1, 'param2': 2}
+config = Config(my_dict)
+```
+
+You can also add built-in or custom processings:
+
+```python
+from cliconfig import Config, create_processing_value
+from cliconfig.processing.builtin import ProcessCopy
+my_dict = {'param1': 1, 'param2': 2}
+my_proc = create_processing_value(lambda x: x+1, "premerge", tag_name='add1')
+config = Config(my_dict, [my_proc, ProcessCopy()])
+```
+
+### From a yaml file without command line arguments (useful for notebooks)
+
+```python
+
+from cliconfig import make_config
+config = make_config('my_yaml_file.yaml', no_cli=True)
+```
+
+You can merge multiple yaml files that will be considered as default configs
+(new parameter names are allowed).
+
+```python
+from cliconfig import make_config
+config = make_config('config1.yaml', 'config2.yaml', no_cli=True)
+```
+
+You can also pass a list of processing objects like usual.
+
+### From a config (make a copy)
+
+```python
+from cliconfig.config_routines import copy_config
+config2 = copy_config(config)
+```
+
+### From two dicts (or configs) to merge one into the other
+
+```python
+from cliconfig import Config, update_config
+new_config = update_config(Config(config1), config2)  # if config1 is a dict
+new_config = update_config(config1, config2)  # if config1 is a Config
+```
+
+These two lines work whether the config2 is a dict or a Config.
+Note that the second config will override the first one.
+
+### From a list of arguments
+
+Assuming the arguments are under the format
+`['--key1=value1', '--key2.key3=value2']`:
+
+```python
+from cliconfig import Config, unflatten_config
+from cliconfig.cli_parser import parse_cli
+my_args = ['--key1=value1', '--key2.key3=value2']
+config = Config(parse_cli(my_args)[0])  # flat
+config = unflatten_config(config)
+```
+
+### From a yaml formatted string
+
+```python
+from yaml import safe_load
+from cliconfig import Config, unflatten_config
+yaml_txt = """
+a:
+  d: [2, 3]
+  b.c: {d: 4, e: 5}
+"""
+config = Config(safe_load(yaml_txt))  # mix flat and nested
+config = unflatten_config(config)
+```
+
+## Hyperparameter search with Weights&Biases
+
+Making hyperparameter search easier and more effective with
+[Weights&Biases sweeps](https://wandb.ai/site)! This example shows you how to combine them
+with cliconfig supporting nested configuration:
+
+```python
+# main.py
+from cliconfig.config_routines import update_config
+from cliconfig.dict_routines import flatten
+import wandb
+
+def main() -> None:
+    """Main function."""
+    # Create a cliconfig based on CLI
+    config = make_config('default.yaml')
+    # Initialize wandb to create wandb.config eventually modified by sweep
+    # Note that the config is flattened because wandb sweep does not support
+    # nested config (yet)
+    wandb.init(config=flatten(config.dict))
+    # Sync the cliconfig with wandb.config
+    config = update_config(config, wandb.config)
+    # Now the config is eventually updated with the sweep,
+    # unflattened and ready to be used
+
+    run(config)
+
+if __name__ == '__main__':
+    main()
+```
+
+Now you can create your sweep configuration use wandb sweep either from CLI or
+from python following the [wandb tutorial](https://docs.wandb.ai/guides/sweeps).
+
+For instance with a configuration containing train and data sub-configurations:
+
+```yaml
+# sweep.yaml
+program: main.py
+method: bayes
+metric:
+  name: val_loss
+  goal: minimize
+parameters:
+    train.learning_rate:
+        distribution: log_uniform_values
+        min: 0.0001
+        max: 0.1
+    train.optimizer.name:
+        values: ["adam", "sgd"]
+    data.batch_size:
+        values: [32, 64, 128]
+```
+
+```bash
+$ wandb sweep sweep.yaml
+sweep_id: ...
+$ wandb agent <sweep_id>
+```
+
+This makes a bayesian search over the learning rate, the optimizer and the batch size
+to minimize the final validation loss.
+
+## How to contribute
+
+For **development**, install the package dynamically and dev requirements with:
+
+```bash
+pip install -e .
+pip install -r requirements-dev.txt
+```
+
+Everyone can contribute to CLI Config, and we value everyone’s contributions. Please see our
+[contributing guidelines](https://github.com/valentingol/cliconfig/blob/main/CONTRIBUTING.md)
+for more information 🤗
diff --git a/README.md b/README.md
index e86af66..932d7da 100644
--- a/README.md
+++ b/README.md
@@ -3,23 +3,18 @@
 ## *Build your experiment configurations for complex projects with robustness, flexibility and simplicity*
 
 <p align="center">
-  <img src="docs/_static/logo_extend.png" />
+  <img src="https://raw.githubusercontent.com/valentingol/cliconfig/main/docs/_static/logo_extend_black.png" />
 </p>
 
-*CLI Config*: Lightweight library that provides routines to merge nested configs
-and set parameters from command line. It is also provide processing functions
-that can change the whole configuration before and after each config merge, config
-saving, config loading and at the end of config building. It also contains many
-routines to manipulate the config as flatten or nested dictionaries.
+*CLI Config* is a lightweight library that provides routines to merge nested configs
+and set parameters from command line. It contains many routines to create and manipulate
+the config as flatten or nested python dictionaries. It also provides processing functions
+that can change the whole configuration before and after each config manipulation.
 
-The package is initially designed for machine learning experiments where the
+The package was initially designed for machine learning experiments where the
 number of parameters is huge and a lot of them have to be set by the user between
 each experiment. If your project matches this description, this package is for you!
 
-## Documentation :memo: [here](https://cliconfig.readthedocs.io/en/latest)
-
-## Pypi :package: [here](https://pypi.org/project/cliconfig/)
-
 [![Release](https://img.shields.io/github/v/tag/valentingol/cliconfig?label=Pypi&logo=pypi&logoColor=yellow)](https://pypi.org/project/cliconfig/)
 ![PythonVersion](https://img.shields.io/badge/Python-3.7%20%7E%203.11-informational)
 [![License](https://img.shields.io/github/license/valentingol/cliconfig?color=999)](https://stringfixer.com/fr/MIT_license)
@@ -35,12 +30,23 @@ each experiment. If your project matches this description, this package is for y
 
 [![Tests](https://github.com/valentingol/cliconfig/actions/workflows/tests.yaml/badge.svg)](https://github.com/valentingol/cliconfig/actions/workflows/tests.yaml)
 [![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/valentingol/098e9c7c53be88779ee52ef2f2bc8803/raw/cliconfig_tests.json)](https://github.com/valentingol/cliconfig/actions/workflows/tests.yaml)
-[![Documentation Status](https://readthedocs.org/projects/cliconfig/badge/?version=latest)](https://cliconfig.readthedocs.io/en/latest/?badge=latest)
+[![Documentation Status](https://github.com/valentingol/cliconfig/actions/workflows/pdoc.yaml/badge.svg)](https://valentingol.github.io/cliconfig)
 
 Official badge :
 [![Config](https://custom-icon-badges.demolab.com/badge/cliconfig-black?logo=cliconfig)](https://github.com/valentingol/cliconfig)
 
-## Installation
+## Documentation :memo:
+
+Here you can find the [documentation](https://valentingol.github.io/cliconfig) (built with pdoc3).
+
+The old documentation with readthedocs (1.3.6, not up to date) is also available
+[here](cliconfig.readthedocs.io).
+
+## Pypi :package:
+
+Here you can find the [Pypi page](https://pypi.org/project/cliconfig/).
+
+## Installation :inbox_tray:
 
 In a new virtual environment, simply install the package with:
 
@@ -50,14 +56,13 @@ pip install cliconfig
 
 This package is OS independent and supported on Linux, macOS and Windows.
 
-## Quick start
+## Minimal example :rocket:
 
-See the [Quick start](https://cliconfig.readthedocs.io/en/latest/quickstart.html) section of
-the documentation for a quick overview.
-
-### Minimal example
+See the [*Quick Start*](https://valentingol.github.io/cliconfig/cliconfig.html#quick-start)
+section of the documentation for a quick overview.
 
-Make default config yaml file(s) in your project (configs are merged from left to right):
+First of all, make default config yaml file(s) in your project (configs are merged from left to right).
+Then you can create a config object from these files in python:
 
 ```python
 # main.py
@@ -65,7 +70,7 @@ from cliconfig import make_config
 config = make_config('default1.yaml', 'default2.yaml')
 ```
 
-Then launch your script with additional config file(s) and individual parameters by command line.
+Now launch your script with eventually additional config file(s) and parameters by command line.
 The additional configs are merged on the default one's then the parameters are set.
 
 ```bash
@@ -75,32 +80,35 @@ python main.py --config first.yaml,second.yaml --param1=1 --subconfig.param2='fo
 **By default, these additional configs cannot add new parameters to the default config
 (for security and retro-compatibility reasons).**
 
-See the [Edge cases](https://cliconfig.readthedocs.io/en/latest/edge_cases.html) section of
-the documentation for some edge cases due to implementation.
+See the [*Edge cases*](https://valentingol.github.io/cliconfig/cliconfig.html#edge-cases)
+section of the documentation for some edge cases due to implementation.
 
-## Tags
+## Tags :label:
 
-You can add tags `@<tag_name>` at the end of parameters name to activate some features. See the [Quick start](https://cliconfig.readthedocs.io/en/latest/quickstart.html)
+You can add tags `@<tag_name>` at the end of parameters names to activate some features.
+See the [*Quick start*](https://valentingol.github.io/cliconfig/cliconfig.html#quick-start)
 section of the documentation for a quick overview.
 
 The default tags include:
 
 * `@merge_add`, `@merge_before`, and `@merge_after`: merge other yaml files
   indicating the path
-* `@copy`: copy an other parameter
-* `@def`: safely evaluate an expression to define the parameter value
+* `@copy`: copy another parameter
+* `@def`: safely evaluate an expression to define the parameter value (a lot of
+  operations and functions are available)
 * `@type:<my type>`: try to convert and check the type
 * `@select`: target a sub-config and delete the sub-config in the parent config
 * `@delete`: delete the key from the config
-* `@new`: allow to add parameters that are not present in default config
+* `@new`: allow adding parameters that are not present in default config
 * `@dict`: create a dict object that is not a sub-config (see the
-  [*Edge cases*](https://cliconfig.readthedocs.io/en/latest/edge_cases.html) section)
+* [*Edge cases*](https://valentingol.github.io/cliconfig/cliconfig.html#edge-cases) section)
 
 It is also easy to create your own features and possibilities are endless. The way to do
-it are explained in the [*Processing*](https://cliconfig.readthedocs.io/en/latest/processing.html)
+it are explained in the
+[*Processing*](https://valentingol.github.io/cliconfig/cliconfig.html#processing)
 section of the documentation.
 
-## How to contribute
+## How to contribute 👷
 
 For **development**, install the package dynamically and dev requirements with:
 
@@ -112,7 +120,7 @@ pip install -r requirements-dev.txt
 Everyone can contribute to CLI Config, and we value everyone’s contributions.
 Please see our [contributing guidelines](CONTRIBUTING.md) for more information 🤗
 
-## License, usage and distribution
+## License, usage and distribution :scroll:
 
 This program is free software under the [MIT License](LICENSE).
 
diff --git a/cliconfig/__init__.py b/cliconfig/__init__.py
index a2af6c7..a49433e 100644
--- a/cliconfig/__init__.py
+++ b/cliconfig/__init__.py
@@ -8,7 +8,20 @@
 #    This project is free to use for COMMERCIAL USE, MODIFICATION,
 #    DISTRIBUTION and PRIVATE USE as long as the original license is
 #    include as well as this copy right notice.
-"""CLI Config: build your configuration from CLI by merging with processing."""
+"""# CLI Config - Build your python configurations with flexibility and simplicity.
+
+.. include:: ../DOCUMENTATION.md
+"""
+
+from cliconfig import (
+    base,
+    cli_parser,
+    config_routines,
+    dict_routines,
+    process_routines,
+    processing,
+    tag_routines,
+)
 from cliconfig._version import __version__, __version_tuple__
 from cliconfig.base import Config
 from cliconfig.config_routines import (
@@ -39,16 +52,23 @@
     "Config",
     "DefaultProcessings",
     "Processing",
+    "base",
+    "cli_parser",
+    "config_routines",
     "create_processing_keep_property",
     "create_processing_value",
+    "dict_routines",
     "flatten_config",
     "flatten_dict",
     "make_config",
     "load_config",
     "merge_flat_paths_processing",
     "merge_flat_processing",
+    "process_routines",
+    "processing",
     "save_config",
     "show_config",
+    "tag_routines",
     "unflatten_config",
     "unflatten_dict",
     "update_config",
diff --git a/cliconfig/base.py b/cliconfig/base.py
index 46d56f3..a3f3dc8 100644
--- a/cliconfig/base.py
+++ b/cliconfig/base.py
@@ -1,5 +1,5 @@
 # Copyright (c) 2023 Valentin Goldite. All Rights Reserved.
-"""Base classes of Config object."""
+"""Base class of Config objects."""
 from typing import TYPE_CHECKING, Any, Dict, List, Optional
 
 # Imports Processing for mypy type checking only
@@ -10,9 +10,9 @@
 class Config:
     """Class for configuration.
 
-    Config object contain the config dict and the processing list
-    and no methods except ``__init__``, ``__repr__``, ``__eq__``,
-    ``__getattribute__``, ``__setattr__`` and ``__delattr__``.
+    Config object contains the config dict and the processing list
+    and no methods except `__init__`, `__repr__`, `__eq__`,
+    `__getattribute__`, `__setattr__` and `__delattr__`.
     The Config objects are mutable and not hashable.
 
     Parameters
@@ -22,6 +22,20 @@ class Config:
     process_list : Optional[List[Processing]], optional
         The list of Processing objects. If None, an empty list is used.
         The default is None.
+
+    Examples
+    --------
+    ```python
+    >>> config = Config({"a": 1, "b": {"c": 2}})
+    >>> config.dict
+    {"a": 1, "b": {"c": 2}}
+    >>> config.process_list
+    []
+    >>> config.b
+    Config({"c": 2}, [])
+    >>> config.b.c
+    2
+    ```
     """
 
     def __init__(
@@ -64,7 +78,7 @@ def __getattribute__(self, __name: str) -> Any:
         """Get attribute, sub-configuration or parameter.
 
         The dict should be nested (unflattened). If it is not the case,
-        you can apply :func:`dict_routines.flatten` on ``config.dict``
+        you can apply `cliconfig.dict_routines.flatten` on `config.dict`
         to unflatten it.
         """
         if __name in ["dict", "process_list"]:
@@ -84,7 +98,7 @@ def __setattr__(self, __name: str, value: Any) -> None:
         """Set attribute, sub-configuration or parameter.
 
         The dict should be nested (unflattened). If it is not the case,
-        you can apply :func:`dict_routines.flatten` on ``config.dict``
+        you can apply `cliconfig.dict_routines.flatten` on `config.dict`
         to unflatten it.
         """
         if __name in ["dict", "process_list"]:
@@ -96,7 +110,7 @@ def __delattr__(self, __name: str) -> None:
         """Delete attribute, sub-configuration or parameter.
 
         The dict should be nested (unflattened). If it is not the case,
-        you can apply :func:`dict_routines.flatten` on ``config.dict``
+        you can apply `cliconfig.dict_routines.flatten` on `config.dict`
         to unflatten it.
         """
         if __name in ["dict", "process_list"]:
diff --git a/cliconfig/cli_parser.py b/cliconfig/cli_parser.py
index 7730401..28aaa9f 100644
--- a/cliconfig/cli_parser.py
+++ b/cliconfig/cli_parser.py
@@ -1,18 +1,18 @@
 # Copyright (c) 2023 Valentin Goldite. All Rights Reserved.
-"""Parser for CLI commands."""
+"""Parsing functions from CLI."""
 from typing import Any, Dict, List, Tuple
 
 import yaml
 
 
 def parse_cli(sys_argv: List[str]) -> Tuple[List[str], Dict[str, Any]]:
-    """Parser for CLI commands.
+    """Parser for CLI.
 
-    Return list of config path(s) that are detected with ``--config`` followed
+    Return a list of config path(s) that are detected with `--config` followed
     by a space. If multiple paths are provided, they must be separated by a comma
     and no space around the comma. It also possible to provide a list of paths.
 
-    Return also a dictionary of parameters from CLI detected with ``--<key>=<value>``
+    Return also a dictionary of parameters from CLI detected with `--<key>=<value>`
     (with "=" and without spaces around). If no value is provided,
     it is True by default (like for a flag).
 
@@ -24,7 +24,7 @@ def parse_cli(sys_argv: List[str]) -> Tuple[List[str], Dict[str, Any]]:
     Raises
     ------
     Value Error
-        If the ``--config`` argument (with space) is used more than once.
+        If the `--config` argument (with space) is used more than once.
 
     Returns
     -------
@@ -35,14 +35,14 @@ def parse_cli(sys_argv: List[str]) -> Tuple[List[str], Dict[str, Any]]:
 
     Examples
     --------
-    .. code-block:: text
+    ```script
+    python my_script.py --config config.yaml --foo.bar.param=[1, 2, 3]
+    ```
 
-        $ python my_script.py --config config.yaml --foo.bar.param=[1, 2, 3]
-
-    Will be parsed as ``config_paths=['config.yaml']``
-    and ``cli_params={'foo.bar.param': [1, 2, 3]}``.
-    It is equivalent to: ``{'foo': {'bar': {'param': [1, 2, 3]}}`` for
-    :func:`.merge_flat` and :func:`.make_config`.
+    Will be parsed as `config_paths=['config.yaml']`
+    and `cli_params={'foo.bar.param': [1, 2, 3]}`.
+    It is equivalent to: `{'foo': {'bar': {'param': [1, 2, 3]}}` for
+    `cliconfig.dict_routines.merge_flat` and `cliconfig.config_routines.make_config`.
     """
     cli_params_dict: Dict[str, Any] = {}
     config_paths: List[str] = []
diff --git a/cliconfig/config_routines.py b/cliconfig/config_routines.py
index 1f46c9e..012bb1e 100644
--- a/cliconfig/config_routines.py
+++ b/cliconfig/config_routines.py
@@ -1,5 +1,5 @@
 # Copyright (c) 2023 Valentin Goldite. All Rights Reserved.
-"""Functions to manipulate config as dict with yaml files and CLI."""
+"""Low-level and high-level functions to manipulate config."""
 import sys
 from copy import deepcopy
 from typing import Any, Dict, List, Optional, Union
@@ -28,8 +28,9 @@ def make_config(
 ) -> Config:
     r"""Make a config from default config(s) and CLI argument(s) with processing.
 
-    The function uses the CLI Config routines :func:`.parse_cli` to parse the CLI
-    arguments and merge them with :func:`.merge_flat_paths_processing`, applying
+    The function uses the CLI Config routines `cliconfig.cli_parser.parse_cli`
+    to parse the CLI arguments and merge them with
+    `cliconfig.process_routines.merge_flat_paths_processing`, applying
     the pre-merge and post-merge processing functions on each merge.
 
     Parameters
@@ -42,11 +43,11 @@ def make_config(
         By default None.
     add_default_processing : bool, optional
         If add_default_processing is True, the default processings
-        (found on :class:`.DefaultProcessings`) are added to the list of
-        processings. By default True.
+        (found on `cliconfig.processing.builtin.DefaultProcessings`) are added to
+        the list of processings. By default True.
     fallback : str, optional
         Path of the configuration to use if no additional config is provided
-        with ``--config``. No fallback config if empty string (default),
+        with `--config`. No fallback config if empty string (default),
         in that case, the config is the default configs plus the CLI arguments.
     no_cli : bool, optional
         If True, the CLI arguments are not parsed and the config is only
@@ -67,22 +68,21 @@ def make_config(
 
     Note
     ----
-
-        Setting additional arguments from CLI that are not in default configs
-        does NOT raise an error but only a warning. This ensures the compatibility
-        with other CLI usage (e.g notebook, argparse, etc.)
+    Setting additional arguments from CLI that are not in default configs
+    does NOT raise an error but only a warning. This ensures the compatibility
+    with other CLI usage (e.g notebook, argparse, etc.)
 
     Examples
     --------
-    ::
-
-        # main.py
-        config = make_config('data.yaml', 'model.yaml', 'train.yaml')
+    ```python
+    # main.py
+    config = make_config('data.yaml', 'model.yaml', 'train.yaml')
+    ```
 
-    .. code-block:: text
-
-        $ python main.py -- config [bestmodel.yaml,mydata.yaml] \
-              --architecture.layers.hidden_dim=64
+    ```script
+    python main.py -- config [bestmodel.yaml,mydata.yaml] \
+        --architecture.layers.hidden_dim=64
+    ```
 
     """
     # Create the processing list
@@ -169,8 +169,8 @@ def load_config(
         If None, no processing is applied. By default None.
     add_default_processing : bool, optional
         If add_default_processing is True, the default processings
-        (found on :class:`.DefaultProcessings`) are added to the list of
-        processings. By default True.
+        (found on `cliconfig.processing.builtin.DefaultProcessings`)
+        are added to the list of processings. By default True.
 
     Returns
     -------
@@ -181,10 +181,9 @@ def load_config(
 
     Note
     ----
-
-        If default configs are provided, the function does not allow new keys
-        for the loaded config. This is for helping the user to see how to
-        adapt the config file if the default configs have changed.
+    If default configs are provided, the function does not allow new keys
+    for the loaded config. This is for helping the user to see how to
+    adapt the config file if the default configs have changed.
     """
     # Crate process_list
     process_list_: List[Processing] = [] if process_list is None else process_list
@@ -220,7 +219,7 @@ def load_config(
 def save_config(config: Config, path: str) -> None:
     """Save a config and apply pre-save processing before saving.
 
-    Alias for :func:`.save_processing`.
+    Alias for `cliconfig.process_routines.save_processing`.
 
     Parameters
     ----------
diff --git a/cliconfig/dict_routines.py b/cliconfig/dict_routines.py
index 034e305..41fa6ec 100644
--- a/cliconfig/dict_routines.py
+++ b/cliconfig/dict_routines.py
@@ -1,7 +1,7 @@
 # Copyright (c) 2023 Valentin Goldite. All Rights Reserved.
-"""Routines to manipulate nested and flat dictionaries (and mix of both).
+"""Low-level routines to manipulate nested and flat dictionaries (and mix of both).
 
-Used by :mod:`.process_routines` and :mod:`.config_routines`.
+Used by `cliconfig.process_routines` and `cliconfig.config_routines`.
 """
 import os
 from typing import Any, Dict, Tuple, Union
@@ -25,9 +25,9 @@ def merge_flat(
     Work even if dict1 and dict2 have a mix of nested and flat
     dictionaries. For instance like this:
 
-    .. code-block:: python
-
-        dict1 = {'a.b': 1, 'a': {'c': 2}, 'a.d': {'e.f': 3}}
+    ```python
+    dict1 = {'a.b': 1, 'a': {'c': 2}, 'a.d': {'e.f': 3}}
+    ```
 
     Parameters
     ----------
@@ -45,7 +45,7 @@ def merge_flat(
         If allow_new_keys is False and dict2 has new keys that are not in dict1.
     ValueError
         If there are conflicting keys when flatten one of the dicts.
-        See last example. You may consider calling :func:`clean_pre_flat` on the input
+        See last example. You may consider calling `clean_pre_flat` on the input
         dicts in that case.
 
     Returns
@@ -55,16 +55,16 @@ def merge_flat(
 
     Examples
     --------
-    .. code-block:: python
-
-        >>> merge_dict({'a.b': 1, 'a': {'c': 2}},  {'c': 3}, allow_new_keys=True)
-        {'a.b': 1, 'a.c': 2, 'c': 3}
-        >>> merge_dict({'a.b': 1, 'a': {'c': 2}},  {'c': 3}, allow_new_keys=False)
-        ValueError: New parameter found 'c' that is not in the original dict.
-        >>> merge_dict({'a.b': 1, 'a': {'b': 1}},  {'c': 3}, allow_new_keys=True)
-        ValueError: duplicated key 'a.b'.
-        The above exception was the direct cause of the following exception:
-        ValueError: You may consider calling 'clean_pre_flat' on dict 1 before merging.
+    ```python
+    >>> merge_dict({'a.b': 1, 'a': {'c': 2}},  {'c': 3}, allow_new_keys=True)
+    {'a.b': 1, 'a.c': 2, 'c': 3}
+    >>> merge_dict({'a.b': 1, 'a': {'c': 2}},  {'c': 3}, allow_new_keys=False)
+    ValueError: New parameter found 'c' that is not in the original dict.
+    >>> merge_dict({'a.b': 1, 'a': {'b': 1}},  {'c': 3}, allow_new_keys=True)
+    ValueError: duplicated key 'a.b'.
+    The above exception was the direct cause of the following exception:
+    ValueError: You may consider calling 'clean_pre_flat' on dict 1 before merging.
+    ```
     """
     # Flatten dicts
     flat_dict1, flat_dict2 = _flat_before_merge(dict1, dict2)
@@ -113,7 +113,7 @@ def merge_flat_paths(
 ) -> Dict[str, Any]:
     """Flatten then merge two dict eventually loaded from yaml file paths.
 
-    Similar to :func:`.merge_flat` but allow passing the paths of dicts
+    Similar to `merge_flat` but allow passing the paths of dicts
     as inputs. It merges the second dict into the first one.
 
     Parameters
@@ -132,7 +132,7 @@ def merge_flat_paths(
         If allow_new_keys is False and dict2 has new keys that are not in dict1.
     ValueError
         If there are conflicting keys when flatten one of the dicts.
-        See last example. You may consider calling :func:`clean_pre_flat` on the input
+        See last example. You may consider calling `clean_pre_flat` on the input
         dicts in that case.
 
     Returns
@@ -162,10 +162,10 @@ def flatten(in_dict: Dict[str, Any]) -> Dict[str, Any]:
     Work even if in_dict is a mix of nested and flat dictionaries.
     For instance like this:
 
-    .. code-block:: python
-
-        >>> flatten({'a.b': {'c': 1}, 'a': {'b.d': 2}, 'a.e': {'f.g': 3}})
-        {'a.b.c': 1, 'a.b.d': 2, 'a.e.f.g': 3}
+    ```python
+    >>> flatten({'a.b': {'c': 1}, 'a': {'b.d': 2}, 'a.e': {'f.g': 3}})
+    {'a.b.c': 1, 'a.b.d': 2, 'a.e.f.g': 3}
+    ```
 
 
     Parameters
@@ -176,7 +176,7 @@ def flatten(in_dict: Dict[str, Any]) -> Dict[str, Any]:
     Raises
     ------
     ValueError
-        If dict has some conflicting keys (like ``{'a.b': <x>, 'a': {'b': <y>}}``).
+        If dict has some conflicting keys (like `{'a.b': <x>, 'a': {'b': <y>}}`).
 
     Returns
     -------
@@ -185,20 +185,20 @@ def flatten(in_dict: Dict[str, Any]) -> Dict[str, Any]:
 
     Note
     ----
-        Nested empty dict are ignored even if they are conflicting (see last example).
+    Nested empty dict are ignored even if they are conflicting (see last example).
 
     Examples
     --------
-    ::
-
-        >>> flatten({'a.b': 1, 'a': {'c': 2}, 'd': 3})
-        {'a.b': 1, 'a.c': 2, 'd': 3}
-        >>> flatten({'a.b': {'c': 1}, 'a': {'b.d': 2}, 'a.e': {'f.g': 3}})
-        {'a.b.c': 1, 'a.b.d': 2, 'a.e.f.g': 3}
-        >>> flatten({'a.b': 1, 'a': {'b': 1}})
-        ValueError: duplicated key 'a.b'
-        >>> flatten({'a.b': 1, 'a': {'c': {}}, 'a.c': 3})
-        {'a.b': 1, 'a.c': 3}
+    ```python
+    >>> flatten({'a.b': 1, 'a': {'c': 2}, 'd': 3})
+    {'a.b': 1, 'a.c': 2, 'd': 3}
+    >>> flatten({'a.b': {'c': 1}, 'a': {'b.d': 2}, 'a.e': {'f.g': 3}})
+    {'a.b.c': 1, 'a.b.d': 2, 'a.e.f.g': 3}
+    >>> flatten({'a.b': 1, 'a': {'b': 1}})
+    ValueError: duplicated key 'a.b'
+    >>> flatten({'a.b': 1, 'a': {'c': {}}, 'a.c': 3})
+    {'a.b': 1, 'a.c': 3}
+    ```
 
     """
     flat_dict = _flatten(in_dict, reducer="dot")
@@ -226,13 +226,13 @@ def unflatten(flat_dict: Dict[str, Any]) -> Dict[str, Any]:
 
     Examples
     --------
-    ::
-
-        >>> unflatten({'a.b': 1, 'a.c': 2, 'c': 3})
-        {'a': {'b': 1, 'c': 2}, 'c': 3}
-        >>> unflatten({'a.b': 1, 'a': {'c': 2}})
-        ValueError: duplicated key 'a'
-        The dict must be flatten before calling unflatten function.
+    ```python
+    >>> unflatten({'a.b': 1, 'a.c': 2, 'c': 3})
+    {'a': {'b': 1, 'c': 2}, 'c': 3}
+    >>> unflatten({'a.b': 1, 'a': {'c': 2}})
+    ValueError: duplicated key 'a'
+    The dict must be flatten before calling unflatten function.
+    ```
     """
     try:
         unflat_dict = _unflatten(flat_dict, splitter="dot")
@@ -254,8 +254,8 @@ def clean_pre_flat(in_dict: Dict[str, Any], priority: str) -> Dict[str, Any]:
         See warning section below.
     priority: str
         One of 'flat' or 'unflat', 'error'.
-        If 'flat', keys with dots at the root like ``{'a.b': ...}`` (flat keys) have
-        priority over nested keys like ``{'a': {'b': ...}}`` when there are conflicts.
+        If 'flat', keys with dots at the root like `{'a.b': ...}` (flat keys) have
+        priority over nested keys like `{'a': {'b': ...}}` when there are conflicts.
         If 'unflat', nested keys have priority over flat keys when there are conflicts.
         If 'error', raise an error when there are conflicts.
 
@@ -271,23 +271,23 @@ def clean_pre_flat(in_dict: Dict[str, Any], priority: str) -> Dict[str, Any]:
 
     Warning
     -------
-        * No flat key can contain a dict. Then, dicts like ``{'a.b': {'c': 1}}``
-          are not supported.
-        * All the keys that contain dots (the flat keys) must be at the root.
-          Then, dicts like ``{a: {'b.c': 1}}`` are not supported.
-        * To summarize, the dict must contain only fully flat dicts
-          and/or fully nested dicts.
+    * No flat key can contain a dict. Then, dicts like `{'a.b': {'c': 1}}`
+      are not supported.
+    * All the keys that contain dots (the flat keys) must be at the root.
+      Then, dicts like `{a: {'b.c': 1}}` are not supported.
+    * To summarize, the dict must contain only fully flat dicts
+      and/or fully nested dicts.
 
     Examples
     --------
-    ::
-
-        >>> clean_pre_flat({'a.b': 1, 'a': {'b': 2}, 'c': 3}, priority='flat')
-        {'a.b': 1, 'c': 3}
-        >>> clean_pre_flat({'a.b': 1, 'a': {'b': 2}, 'c': 3}, priority='unflat')
-        {'a': {'b': 2}, 'c': 3}
-        >>> clean_pre_flat({'a.b': 1, 'a': {'b': 2}, 'c': 3}, priority='error')
-        ValueError: duplicated key 'a.b'
+    ```python
+    >>> clean_pre_flat({'a.b': 1, 'a': {'b': 2}, 'c': 3}, priority='flat')
+    {'a.b': 1, 'c': 3}
+    >>> clean_pre_flat({'a.b': 1, 'a': {'b': 2}, 'c': 3}, priority='unflat')
+    {'a': {'b': 2}, 'c': 3}
+    >>> clean_pre_flat({'a.b': 1, 'a': {'b': 2}, 'c': 3}, priority='error')
+    ValueError: duplicated key 'a.b'
+    ```
     """
     if priority in ("flat", "unflat"):
         # Check that there are no conflicts
@@ -341,28 +341,28 @@ def _del_key(
 
     Warning
     -------
-        * No flat key can contain a dict. Then, dicts like ``{'a.b': {'c': 1}}``
-          are not supported.
-        * All the keys that contain dots (the flat keys) must be at the root.
-          Then, dicts like ``{a: {'b.c': 1}}`` are not supported.
-        * To summarize, the dict must contain only fully flat dicts
-          and fully nested dicts.
+    * No flat key can contain a dict. Then, dicts like `{'a.b': {'c': 1}}`
+        are not supported.
+    * All the keys that contain dots (the flat keys) must be at the root.
+        Then, dicts like `{a: {'b.c': 1}}` are not supported.
+    * To summarize, the dict must contain only fully flat dicts
+        and fully nested dicts.
 
     Examples
     --------
-    ::
-
-        >>> in_dict = {'a': {'b': {'c': 1}, 'd': 2}, 'a.b.c': 4}
-        >>> _del_key(in_dict, 'a.b.c'); in_dict
-        {'a': {'d': 2}}
-        >>> _del_key(in_dict, 'a.b.c', keep_flat=True); in_dict
-        {'a': {'d': 2}, 'a.b.c': 4}
-        >>> _del_key(in_dict, 'a.b.c', keep_unflat=True); in_dict
-        {'a': {'b': {'c': 1}, 'd': 2}}
-        >>> _del_key(in_dict, 'a.b.z')
-        ValueError: Key 'a.b.z' not found in dict.
-        >>> _del_key(in_dict, 'a.z.c')
-        ValueError: Key 'a.z.c' not found in dict.
+    ```python
+    >>> in_dict = {'a': {'b': {'c': 1}, 'd': 2}, 'a.b.c': 4}
+    >>> _del_key(in_dict, 'a.b.c'); in_dict
+    {'a': {'d': 2}}
+    >>> _del_key(in_dict, 'a.b.c', keep_flat=True); in_dict
+    {'a': {'d': 2}, 'a.b.c': 4}
+    >>> _del_key(in_dict, 'a.b.c', keep_unflat=True); in_dict
+    {'a': {'b': {'c': 1}, 'd': 2}}
+    >>> _del_key(in_dict, 'a.b.z')
+    ValueError: Key 'a.b.z' not found in dict.
+    >>> _del_key(in_dict, 'a.z.c')
+    ValueError: Key 'a.z.c' not found in dict.
+    ```
     """
     found_key = False
     if not keep_flat and flat_key in in_dict:
@@ -439,11 +439,10 @@ def load_dict(path: str) -> Dict[str, Any]:
 
     Note
     ----
-
-        * If multiple yaml files are in the same document, they are merged
-          from the first to the last.
-        * To use multiple yaml tags, separate them with "@". E.g. ``!tag1@tag2``.
-        * You can combine any number of yaml and cliconfig tags together.
+    * If multiple yaml files are in the same document, they are merged
+        from the first to the last.
+    * To use multiple yaml tags, separate them with "@". E.g. `!tag1@tag2`.
+    * You can combine any number of yaml and cliconfig tags together.
     """
     try:
         with open(path, "r", encoding="utf-8") as cfg_file:
diff --git a/cliconfig/process_routines.py b/cliconfig/process_routines.py
index 5973a75..fa0cfd8 100644
--- a/cliconfig/process_routines.py
+++ b/cliconfig/process_routines.py
@@ -1,7 +1,7 @@
 # Copyright (c) 2023 Valentin Goldite. All Rights Reserved.
-"""Routines to manipulate dictionaries with processing.
+"""Low-level routines to manipulate config while triggering processing.
 
-Used by :mod:`.config_routines`.
+Used by `cliconfig.config_routines`.
 """
 from typing import List, Optional, Union
 
@@ -26,7 +26,7 @@ def merge_flat_processing(
     preprocess_second: bool = True,
     postprocess: bool = True,
 ) -> Config:
-    """Flatten, merge config2 into config1 and apply pre and post processing.
+    """Flatten and merge config2 into config1 and apply pre and post processing.
 
     Work even if the config dicts have a mix of nested and flat dictionaries.
     If both arguments are configs, the process lists are merged before applying
@@ -107,7 +107,7 @@ def merge_flat_paths_processing(
 ) -> Config:
     """Flatten, merge and apply processing to two configs or their yaml paths.
 
-    Similar to :func:`merge_flat_processing` but allows to pass configs
+    Similar to `merge_flat_processing` but allows to pass configs
     or their yaml paths. Work even if the configs have a mix of nested and flat dicts.
     If both arguments are configs, the process lists are merged before applying
     the processing. The duplicate processings (with same internal variables)
@@ -230,6 +230,9 @@ def load_processing(path: str, process_list: List[Processing]) -> Config:
 def end_build_processing(flat_config: Config) -> Config:
     """Apply end-build processings to a flat config.
 
+    Typically used after operations that provide a usable config.
+    This fonction doesn't unflatten the dict.
+
     Parameters
     ----------
     flat_config : Config
diff --git a/cliconfig/processing/__init__.py b/cliconfig/processing/__init__.py
index 0553e0e..4907b7e 100644
--- a/cliconfig/processing/__init__.py
+++ b/cliconfig/processing/__init__.py
@@ -1,6 +1,27 @@
 # Copyright (c) 2023 Valentin Goldite. All Rights Reserved.
-"""Processing module to apply functions on merge (before and after), save and load."""
+"""#Processing module.
 
-from cliconfig.processing.base import Processing
+Processings are used to manipulate the config behavior when doing a manipulation
+like merging, saving, loading, etc.
 
-__all__ = ["Processing"]
+Each processing is a class that contains 5 methods that change the config
+at different timings:
+
+ * premerge: called before merging
+ * postmerge: called after merging
+ * endbuild: called at the end of the build or an update
+   (via cliconfig.make_config or cliconfig.update_config)
+ * postload: called after loading
+ * presave: called before saving
+
+To create quiclky a processing, you can use the functions
+cliconfig.create_processing_value and cliconfig.create_processing_keep_property.
+To make a more complex processing, you can inherit from cliconfig.Processing and
+customize the methods you want among the five ones.
+
+## Submodules
+
+ * cliconfig.processing.base: Contains base class for processing.
+ * cliconfig.processing.builtin: Contains the default processings.
+ * cliconfig.processing.create: Contains support functions to create processing.
+"""
diff --git a/cliconfig/processing/_type_parser.py b/cliconfig/processing/_type_parser.py
index 476bc09..b91dd41 100644
--- a/cliconfig/processing/_type_parser.py
+++ b/cliconfig/processing/_type_parser.py
@@ -11,14 +11,14 @@ def _parse_type(type_desc: str) -> Tuple:
     nested dicts, unions (with Union or the '|' symbol) and Optional.
 
     Examples of representation:
-     * "str" -> (str,)
-     * "None|Any" -> (type(None), object)
-     * "list|float" -> (list, float)
-     * "List[float]" -> (("list", (float,)),)
-     * "Dict[str, Any]" -> (("dict", (str,), (object,)),)
-     * "Dict[str, List[float]]" -> (("dict", (str,), ((("list", (float,)),),)),)
-     * "Dict[str|bool, int|float]" -> (("dict", (str, bool), (int, float)),)
-     * ...
+    * "str" -> (str,)
+    * "None|Any" -> (type(None), object)
+    * "list|float" -> (list, float)
+    * "List[float]" -> (("list", (float,)),)
+    * "Dict[str, Any]" -> (("dict", (str,), (object,)),)
+    * "Dict[str, List[float]]" -> (("dict", (str,), ((("list", (float,)),),)),)
+    * "Dict[str|bool, int|float]" -> (("dict", (str, bool), (int, float)),)
+    * ...
 
     Raises
     ------
@@ -27,7 +27,7 @@ def _parse_type(type_desc: str) -> Tuple:
 
     Note
     ----
-        The type description is lowercased and spaces are removed before parsing.
+    The type description is lowercased and spaces are removed before parsing.
     """
     # Clean up
     old_type_desc = type_desc
diff --git a/cliconfig/processing/base.py b/cliconfig/processing/base.py
index 6ea6b82..2909b77 100644
--- a/cliconfig/processing/base.py
+++ b/cliconfig/processing/base.py
@@ -1,8 +1,8 @@
 # Copyright (c) 2023 Valentin Goldite. All Rights Reserved.
 """Base class for processing.
 
-Used to make configuration object and run the routines in :mod:`.process_routines`
-and :mod:`.config_routines`.
+Used to make configuration object and run the routines in `cliconfig.process_routines`
+and `cliconfig.config_routines`.
 """
 from cliconfig.base import Config
 
@@ -12,8 +12,8 @@ class Processing:
 
     Each processing classes contains pre-merge, post-merge, pre-save
     and post-load processing. They are used with routines that apply
-    processing in :mod:`.process_routines` and
-    :mod:`.config_routines`.
+    processing in `cliconfig.process_routines` and
+    `cliconfig.config_routines`.
 
     That are applied in the order defined
     by the order attribute in case of multiple processing.
@@ -46,7 +46,8 @@ def endbuild(self, flat_config: Config) -> Config:
         """End-build processing.
 
         Function applied to the flat config to modify it at the end of
-        a building process (typically :func:`.make_config` or :func:`.load_config`).
+        a building process (typically `cliconfig.config_routines.make_config`
+        or `cliconfig.config_routines.load_config`).
         It takes a flat config and returns a flat config.
         """
         return flat_config
@@ -75,7 +76,7 @@ def __eq__(self, __value: object) -> bool:
         """Equality operator.
 
         Two processing are equal if they are the same class and add the same
-        attributes (accessed with ``__dict__``).
+        attributes (accessed with `__dict__`).
         """
         equal = (
             isinstance(__value, self.__class__) and self.__dict__ == __value.__dict__
diff --git a/cliconfig/processing/builtin.py b/cliconfig/processing/builtin.py
index 57a2e26..e1f61b1 100644
--- a/cliconfig/processing/builtin.py
+++ b/cliconfig/processing/builtin.py
@@ -1,11 +1,8 @@
 # Copyright (c) 2023 Valentin Goldite. All Rights Reserved.
 """Built-in processing classes.
 
-Built-in classes to apply pre-merge, post-merge, pre-save and post-load modifications
-to dict with processing routines :mod:`.process_routines`.
-
-They are the default processing used by the config routines :func:`.load_config`
-and :func:`.make_config`.
+Built-in classes of the default processing used by the config routines
+`cliconfig.config_routines.make_config` and `cliconfig.config_routines.load_config`.
 """
 import ast
 from typing import Any, Dict, List, Set, Tuple
@@ -25,16 +22,13 @@
 
 
 class ProcessMerge(Processing):
-    """Merge dicts just in time with ``@merge_after/_before/_add`` tags.
+    """Merge dicts just in time with `@merge_after/_before/_add` tags.
 
-    Tag your key with ``@merge_after``, ``@merge_before`` or ``@merge_add``
-    to load the config corresponding to the value (which is a yaml path) and
-    merge it just before or after the current config. The merged dicts will be
-    processed with pre-merge but not post-merge to ensure that merged
-    configurations are recursively processed with pre-merge before applying a
-    post-merge processing. It allows the merged configs to make references to
-    each other (typically for copy) even without containing the merge tags
-    itself.
+    Tag your key with `@merge_after`, `@merge_before` or `@merge_add`
+    to load the config corresponding to the value (which must be a yaml path) and
+    merge it just before or after the current config. The merging process
+    allows the config files to make references to each other (typically for copy)
+    even without containing the merge tags itself.
 
     * '@merge_add' merges the dict corresponding to the path by allowing ONLY new keys
       It is a security check when you want to add a dict completely new,
@@ -48,34 +42,34 @@ class ProcessMerge(Processing):
 
     Examples
     --------
-    .. code-block:: yaml
-
-        # config1.yaml
-        a:
-          b: 1
-          b_path@merge_after: dict2.yaml
+    ```yaml
+    # config1.yaml
+    a:
+        b: 1
+        b_path@merge_after: dict2.yaml
 
-        # config2.yaml
-        a.b: 2
-        c_path@merge_add: config3.yaml
+    # config2.yaml
+    a.b: 2
+    c_path@merge_add: config3.yaml
 
-        # config3.yaml
-        c: 3
+    # config3.yaml
+    c: 3
+    ```
 
     Before merging, the config1 is interpreted as the dict:
 
-    ::
-
-        {'a': {'b': 2, 'b_path': 'config2.yaml'}, 'c_path': 'config3.yaml', 'c': 3}
+    ```python
+    {'a': {'b': 2, 'b_path': 'config2.yaml'}, 'c_path': 'config3.yaml', 'c': 3}
+    ```
 
     If you replace '@merge_after' by '@merge_before', it will be:
 
-    ::
+    ```python
+    {'a': {'b': 1, 'b_path': 'config2.yaml'}, 'c_path': 'config3.yaml', 'c': 3}
+    ```
 
-        {'a': {'b': 1, 'b_path': 'config2.yaml'}, 'c_path': 'config3.yaml', 'c': 3}
-
-    Finally, if you replace ``@merge_after`` by ``@merge_add``, it will raises an
-    error because the key ``a.b`` already exists in the dict.
+    Finally, if you replace `@merge_after` by `@merge_add`, it will raise an
+    error because the key `a.b` already exists in the dict.
     """
 
     def __init__(self) -> None:
@@ -172,13 +166,13 @@ def premerge(self, flat_config: Config) -> Config:
 
 
 class ProcessCopy(Processing):
-    """Copy a value with ``@copy`` tag. The copy is protected from direct updates.
+    """Copy a value with `@copy` tag. The copy is protected from direct updates.
 
-    Tag your key with ``@copy`` and with value the name of the flat key to copy.
+    Tag your key with `@copy` and with value the name of the flat key to copy.
     The pre-merge processing removes the tag. The post-merge processing
     sets the value (if the copied key exists). The end-build processing checks
     that the key to copy exists and copy them. The pre-save processing
-    restore the tag and the key to copy to keep the information on future loads.
+    restores the tag and the key to copy to keep the information on future loads.
     The post-merge and the end-build processings occur after most processings to
     allow the user to modify or add the copied key before the copy.
     Pre-merge order: 0.0
@@ -188,28 +182,27 @@ class ProcessCopy(Processing):
 
     Examples
     --------
-    .. code-block:: yaml
-
-        # config.yaml
-        a:
-          b: 1
-          c@copy: a.b
+    ```yaml
+    # config.yaml
+    a:
+        b: 1
+        c@copy: a.b
+    ```
 
     Before merging, the config is interpreted as the dict
 
-    .. code-block:: python
-
-        {'a': {'b': 1, 'c': 1}}
+    ```python
+    {'a': {'b': 1, 'c': 1}}
+    ```
 
     Note
     ----
-
-        * The copy key is protected against any modification and will raise an error
-          if you try to modify it but will be updated if the copied key is updated.
-        * If the key to copy does not exist in the config on post-merge, no error
-          is raised to let the user the possibility to add the key later via merge.
-          However, if the key still does not exist at the end of the build
-          (and the key was never copied), an error is raised.
+    * The copy key is protected against any modification and will raise an error
+        if you try to modify it but will be updated if the copied key is updated.
+    * If the key to copy does not exist in the config on post-merge, no error
+        is raised to let the user the possibility to add the key later via merge.
+        However, if the key still does not exist at the end of the build
+        (and the key was never copied), an error is raised.
     """
 
     def __init__(self) -> None:
@@ -301,7 +294,7 @@ def presave(self, flat_config: Config) -> Config:
 
 
 class ProcessDef(Processing):
-    """Dynamically define a value from math expression with ``@def`` tag.
+    """Dynamically define a value from math expression with `@def` tag.
 
     The expression can contain any parameter name of the configuration.
     The most usefull operators and built-in functions are supported,
@@ -320,32 +313,31 @@ class ProcessDef(Processing):
 
     Examples
     --------
-    .. code-block:: yaml
-
-        # config.yaml
-        a:
-          b: 1
-          c: 2
-        d@def: "(a.b + a.c) * 2 > 5"
+    ```yaml
+    # config.yaml
+    a:
+        b: 1
+        c: 2
+    d@def: "(a.b + a.c) * 2 > 5"
+    ```
 
     Before merging, the config is interpreted as the dict
 
-    .. code-block:: python
-
-        {'a': {'b': 1, 'c': 2}, 'd': True}
+    ```python
+    {'a': {'b': 1, 'c': 2}, 'd': True}
+    ```
 
     Now the parameter d is automatically updated if a.b or a.c changes
     while also remaining editable by it-self.
 
     Note
     ----
-
-        * Unlike @copy processing you can change the value by setting
-          an other value or an other definition with @def.
-        * Unlike copy processing all the keys used in expression
-          must be in the config at post-merge.
-        * This processing does not use ``eval`` and is therefore safe from
-          malicious code.
+    * Unlike @copy processing you can change the value by setting
+        an other value or an other definition with @def.
+    * Unlike copy processing all the keys used in expression
+        must be in the config at post-merge.
+    * This processing does not use `eval` and is therefore safe from
+        malicious code.
     """
 
     def __init__(self) -> None:
@@ -411,14 +403,14 @@ def presave(self, flat_config: Config) -> Config:
 
 
 class ProcessTyping(Processing):
-    """Try to convert and force a type with ``@type:<mytype>`` tag.
+    """Try to convert and force a type with `@type:<mytype>` tag.
 
     The type is forced forever.
     Allow basic types (none, any, bool, int, float, str, list, dict), nested lists,
     nested dicts, unions (with Union or the '|' symbol) and Optional.
     The type description is lowercased and spaces are removed.
 
-    For instance: ``@type:None|List[Dict[str, int|float]]`` is valid and force
+    For instance: `@type:None|List[Dict[str, int|float]]` is valid and force
     the type to be None or a list containing dicts with str keys and int or float
     values.
 
@@ -431,32 +423,32 @@ class ProcessTyping(Processing):
 
     Note
     ----
-        * The conversion into union type is from left to right. For instance,
-          ``param@type:List[str|float]: [True]`` is converted to ``["True"]``.
-        * The type is not checked on pre-merge or post-merge to allow the parameter
-          to be updated (by a copy or a merge for instance). The goal of this
-          processing is to ensure the type at the end of the build.
+    * The conversion into union type is from left to right. For instance,
+        `param@type:List[str|float]: [True]` is converted to `["True"]`.
+    * The type is not checked on pre-merge or post-merge to allow the parameter
+        to be updated (by a copy or a merge for instance). The goal of this
+        processing is to ensure the type at the end of the build.
 
 
     Examples
     --------
-    ::
-
-        in_dict = {"param@type:None|List[int|float]": None}
-        dict1 = {param: [0, 1, 2.0]}  # no error
-        dict2 = {param: [0, 1, 2.0, 'a']}  # error
-        dict3 = {param: [0, 1, "2"]}  # no error but convert to [0, 1, 2]
-
-    Merging configs with dictionaries ``in_dict`` and ``dict1`` raises no
-    error and ``param`` is forced to be None or a list of int or float forever.
-    Merging config with ``in_dict`` and ``dict2`` raises an error on post-merge
+    ```python
+    in_dict = {"param@type:None|List[int|float]": None}
+    dict1 = {param: [0, 1, 2.0]}  # no error
+    dict2 = {param: [0, 1, 2.0, 'a']}  # error
+    dict3 = {param: [0, 1, "2"]}  # no error but convert to [0, 1, 2]
+    ```
+
+    Merging configs with dictionaries `in_dict` and `dict1` raises no
+    error and `param` is forced to be None or a list of int or float forever.
+    Merging config with `in_dict` and `dict2` raises an error on post-merge
     due to the 'a' value (which is a string).
-    Merging config with ``in_dict`` and ``dict3`` raises no error and convert
+    Merging config with `in_dict` and `dict3` raises no error and convert
     the value to [0, 1, 2].
 
-    Note that removing "None|" in the type description of ``param`` still
+    Note that removing "None|" in the type description of `param` still
     doesn't raise an error in those cases because the type checking is
-    evaluated after the merge with ``dict2``.
+    evaluated after the merge with `dict2`.
     """
 
     def __init__(self) -> None:
@@ -529,9 +521,9 @@ def presave(self, flat_config: Config) -> Config:
 
 
 class ProcessSelect(Processing):
-    """Select a sub-config with ``@select`` and delete the rest of its parent config.
+    """Select a sub-config with `@select` and delete the rest of its parent config.
 
-    First look in pre-merge for a parameter tagged with ``@select`` containing a
+    First look in pre-merge for a parameter tagged with `@select` containing a
     flat key corresponding to a sub-configurations to keep. The parent configuration
     is then deleted on post-merge, except the selected sub-configuration
     and eventually the tagged parameter (if it is in the same sub-configuration).
@@ -543,31 +535,30 @@ class ProcessSelect(Processing):
 
     Examples
     --------
-    .. code-block:: yaml
-
-        models:
-            model_names@select: [models.model1, models.model3]
-            model1:
-                param1: 1
-                param2: 2
-            model2:
-                param1: 3
-                param2: 4
-            model3:
-                submodel:
-                    param: 5
-            model4:
-                param: 6
-
-    Result in deleting ``models.model2`` (``param1`` and ``param2``) and
-    ``models.model4.param``, and keeping the rest.
+    ```yaml
+    models:
+        model_names@select: [models.model1, models.model3]
+        model1:
+            param1: 1
+            param2: 2
+        model2:
+            param1: 3
+            param2: 4
+        model3:
+            submodel:
+                param: 5
+        model4:
+            param: 6
+    ```
+
+    Result in deleting `models.model2` (`param1` and `param2`) and
+    `models.model4.param`, and keeping the rest.
 
     Warning
     -------
-
-        For security reasons, this processing prevents from deleting
-        the configuration at the root, which is the case when the
-        selected key doesn't contain a dot. It raises an error in this case.
+    For security reasons, this processing prevents from deleting
+    the configuration at the root, which is the case when the
+    selected key doesn't contain a dot. It raises an error in this case.
     """
 
     def __init__(self) -> None:
@@ -648,45 +639,44 @@ def presave(self, flat_config: Config) -> Config:
 
 
 class ProcessDelete(Processing):
-    """Delete the sub-configs/parameters tagged with ``@delete`` on pre-merge.
+    """Delete the sub-configs/parameters tagged with `@delete` on pre-merge.
 
     This processing is useful to activate a processing without adding
     an additional parameter in the default configuration to avoid the error
-    on merge with ``allow_new_keys=False``. This processing is applied very
+    on merge with `allow_new_keys=False`. This processing is applied very
     late on pre-merge to allow the others processing to be applied before
     deleting the parameters.
     Pre-merge order: 30.0
 
     Examples
     --------
-    .. code-block:: yaml
+    ```yaml
+    # main.yaml
+    1@select@delete: configs.config1
+    2@merge_add@delete: config1.yaml
+    3@merge_add@delete: config2.yaml
 
-        # main.yaml
-        1@select@delete: configs.config1
-        2@merge_add@delete: config1.yaml
-        3@merge_add@delete: config2.yaml
+    # config1.yaml
+    configs.config1.param: 1
+    configs.config1.param2: 2
 
-        # config1.yaml
-        configs.config1.param: 1
-        configs.config1.param2: 2
-
-        # config2.yaml
-        configs.config2.param: 3
-        configs.config2.param: 4
+    # config2.yaml
+    configs.config2.param: 3
+    configs.config2.param: 4
+    ```
 
     Here we want to merge two config files and select one sub-config.
     We use the corresponding tags but we don't have a good name for the keys
     and instead of adding a new parameter in the default configuration with
-    random names like "1", "2", "3", we use the ``@delete`` tag to delete the
+    random names like "1", "2", "3", we use the `@delete` tag to delete the
     keys after the pre-merge processing.
 
     Warning
     -------
-
-        The sub-config/parameter is deleted on pre-merge. Therefore, if the parameter
-        also exists on the other configuration during merge (without the tag),
-        this parameter will be remain as it is. This processing is more used
-        to delete parameter that is NOT present in the default configuration.
+    The sub-config/parameter is deleted on pre-merge. Therefore, if the parameter
+    also exists on the other configuration during merge (without the tag),
+    this parameter will be remain as it is. This processing is more used
+    to delete parameter that is NOT present in the default configuration.
     """
 
     def __init__(self) -> None:
@@ -704,7 +694,7 @@ def premerge(self, flat_config: Config) -> Config:
 
 
 class ProcessNew(Processing):
-    """Allow new sub-config/parameter absent from default config with tag ``@new``.
+    """Allow new sub-config/parameter absent from default config with tag `@new`.
 
     The tagged sub-config/parameter and its value is stored during pre-merge and
     is deleted to avoid error on merge due to new key. It is then restored on
@@ -723,34 +713,33 @@ class ProcessNew(Processing):
 
     Examples
     --------
-    .. code-block:: yaml
+    ```yaml
+    # default.yaml
+    param1: 1
 
-        # default.yaml
-        param1: 1
-
-        # additional.yaml
-        param2@new: 2
-        subconfig@new.subsubconfig:
-            param3: 3
-            param4: 4
+    # additional.yaml
+    param2@new: 2
+    subconfig@new.subsubconfig:
+        param3: 3
+        param4: 4
+    ```
 
     Use default.yaml as default configuration and add additional.yaml as additional
     configuration via CLI results on the configuration containing param1, param2
     and the nested config containing param3 and param4.
-    Without the ``@new`` tag, an error is raised because param2 is not present in
+    Without the `@new` tag, an error is raised because param2 is not present in
     the default configuration.
 
     Note
     ----
-
-        * Tag a subconfig by adding ``@new`` at the end of the key containing
-          the sub-config dict in your yaml file.
-        * When a parameter is added with this processing, it is possible to modify it
-          later via config merge without the tag because the parameter is then present
-          in the current configuration.
-        * If the tagged parameter or sub-config is already present in the current
-          configuration, no error are raised and the value is still updated on
-          post-merge. It may no have influence in practice.
+    * Tag a subconfig by adding `@new` at the end of the key containing
+        the sub-config dict in your yaml file.
+    * When a parameter is added with this processing, it is possible to modify it
+        later via config merge without the tag because the parameter is then present
+        in the current configuration.
+    * If the tagged parameter or sub-config is already present in the current
+        configuration, no error are raised and the value is still updated on
+        post-merge. It may no have influence in practice.
     """
 
     def __init__(self) -> None:
@@ -792,15 +781,15 @@ def presave(self, flat_config: Config) -> Config:
 
 
 class ProcessDict(Processing):
-    """Declare a dict instead of a sub-config with ``@dict`` tag.
+    """Declare a dict instead of a sub-config with `@dict` tag.
 
     This processing  can be used to declare a dict where the keys
     are not known in advance or will be modified. New keys are allowed
     in each merge and the element are still available using the dot
-    notation like ``config.subconfig.mydict.something``.
-    The pre-merge processing remove the tag and convert the dict to
+    notation like `config.subconfig.mydict.something`.
+    The pre-merge processing removes the tag and converts the dict to
     a wrapped dict to prevent the flattening. The end-build processing
-    unwrap the dicts to a normal dict. The pre-save processing restore
+    unwraps the dicts to a normal dict. The pre-save processing restores
     the tag to keep the information on future loads.
     Pre-merge order: -30.0
     End-build order: 0.0
@@ -808,37 +797,36 @@ class ProcessDict(Processing):
 
     Examples
     --------
-    .. code-block:: yaml
-
-        # default.yaml
-        param1: 0
-        param2: 2
-        sweep@dict: None
-
-        # additional1.yaml
-        sweep@dict:
-          metric: {name: accuracy, goal: maximize}
-          method: bayes
-          parameters:
-            param1: {min: 0, max: 50}
-
-        # additional2.yaml
-        sweep@dict:
-          name: "random sweep"
-          method: random
-          parameters:
-            param2: {min: 0, max: 10}
-
-    The ``swep`` parameter is considered as a single dict object
+    ```yaml
+    # default.yaml
+    param1: 0
+    param2: 2
+    sweep@dict: None
+
+    # additional1.yaml
+    sweep@dict:
+        metric: {name: accuracy, goal: maximize}
+        method: bayes
+        parameters:
+        param1: {min: 0, max: 50}
+
+    # additional2.yaml
+    sweep@dict:
+        name: "random sweep"
+        method: random
+        parameters:
+        param2: {min: 0, max: 10}
+    ```
+
+    The `swep` parameter is considered as a single dict object
     and not as a sub-config for merging.
 
     Warning
     -------
-
-        * Processings are not applied in the dict keys. In particular,
-          the tags are not used and not removed.
-        * The tag ``@dict`` must be added at the key containing
-          the dict every time you want to modify the dict.
+    * Processings are not applied in the dict keys. In particular,
+        the tags are not used and not removed.
+    * The tag `@dict` must be added at the key containing
+        the dict every time you want to modify the dict.
     """
 
     class PseudoDict:
@@ -958,9 +946,9 @@ class DefaultProcessings:
     """Default list of built-in processings.
 
     To add these processings to a Config instance, use:
-    ::
-
-        config.process_list += DefaultProcessings().list
+    ```python
+    config.process_list += DefaultProcessings().list
+    ```
 
     The current default processing list contains:
      * ProcessCheckTags: protect against '@' in keys at the end of pre-merge)
diff --git a/cliconfig/processing/create.py b/cliconfig/processing/create.py
index ac6c5ff..491980f 100644
--- a/cliconfig/processing/create.py
+++ b/cliconfig/processing/create.py
@@ -10,18 +10,224 @@
 from cliconfig.tag_routines import clean_all_tags, clean_tag, is_tag_in
 
 
-def _is_matched(key: str, tag_name: Optional[str], regex: Optional[str]) -> bool:
-    """Check if key match the regex or contain the tag."""
-    if tag_name is not None and regex is not None:
-        raise ValueError("Either regex or tag_name must be defined but not both.")
-    # Case defined with tag
+def create_processing_value(
+    func: Union[Callable[[Any], Any], Callable[[Any, Config], Any]],
+    processing_type: str = "premerge",
+    *,
+    regex: Optional[str] = None,
+    tag_name: Optional[str] = None,
+    order: float = 0.0,
+    persistent: bool = False,
+) -> Processing:
+    r"""Create a processing object that modifies a value in config using tag or regex.
+
+    The processing is applied on pre-merge. It triggers when the key matches
+    the tag or the regex. The function apply `flat_dict[key] = func(flat_dict[key])`.
+    You must only provide one of tag or regex. If tag is provided, the tag will be
+    removed from the key during pre-merge.
+
+    It also possible to pass the flat config as a second argument of the function
+    `func`. In this case, the function apply
+    `flat_dict[key] = func(flat_dict[key], flat_config)`.
+
+    Parameters
+    ----------
+    func : Callable
+        The function to apply to the value (and eventually the flat config)
+        to make the new value so that:
+        flat_dict[key] = func(flat_dict[key]) or func(flat_dict[key], flat_config)
+    processing_type : str, optional
+        One of "premerge", "postmerge", "presave", "postload" or "endbuild".
+        Timing to apply the value update. In all cases the tag is removed on pre-merge.
+        By default "premerge".
+    regex : Optional[str]
+        The regex to match the key.
+    tag_name : Optional[str]
+        The tag (without "@") to match the key. The tag is removed on pre-merge.
+    order : int, optional
+        The pre-merge order. By default 0.0.
+    persistent : bool, optional
+        If True, the processing will be applied on all keys that have already
+        matched the tag before. By nature, using regex make the processing
+        always persistent. By default, False.
+
+    Raises
+    ------
+    ValueError
+        If both tag and regex are provided or if none of them are provided.
+    ValueError
+        If the processing type is not one of "premerge", "postmerge", "presave",
+        "postload" or "endbuild".
+
+    Returns
+    -------
+    processing : Processing
+        The processing object with the pre-merge method.
+
+
+    Examples
+    --------
+    With the following config and 2 processings:
+
+    ```yaml
+    # config.yaml
+    neg_number1: 1
+    neg_number2: 1
+    neg_number3@add1: 1
+    ```
+
+    ```python
+    # main.py
+    proc2 = create_processing_value(
+        lambda val: -val,
+        regex="neg_number.*",
+        order=0.0
+    )
+    proc1 = create_processing_value(
+        lambda val: val + 1,
+        tag_name="add1",
+        order=1.0
+    )
+    ```
+
+    When config.yaml is merged with an other config, it will be considered
+    before merging as:
+
+    ```python
+    {'number1': -1, 'number2': -1, 'number3': 0}
+    ```
+
+    Using the config as a second argument of the function:
+
+    ```yaml
+    # config.yaml
+    param1: 1
+    param2@eval: "config.param1 + 1"
+    ```
+
+    ```python
+    # main.py
+    proc = create_processing_value(
+        lambda val, config: eval(val, {'config': config}),
+        processing_type='postmerge',
+        tag_name='eval',
+        persistent=False
+    )
+    ```
+
+    After config.yaml is merged with another config, param2 will be evaluated
+    as 2 (except if config.param1 has changed with a processing before).
+    """
     if tag_name is not None:
-        return is_tag_in(key, tag_name)
-    # Case defined with regex
-    if regex is not None:
-        param_name = key.split(".")[-1].split("@")[0]
-        return re.match(regex, param_name) is not None
-    raise ValueError("Either regex or tag_name must be defined.")
+        if regex is not None:
+            raise ValueError("You must provide a tag or a regex but not both.")
+    else:
+        if regex is None:
+            raise ValueError(
+                "You must provide a tag or a regex (to trigger the value update)."
+            )
+    proc = _ProcessingValue(
+        func,
+        processing_type,
+        regex=regex,
+        tag_name=tag_name,
+        order=order,
+        persistent=persistent,
+    )
+    return proc
+
+
+def create_processing_keep_property(
+    func: Callable,
+    regex: Optional[str] = None,
+    tag_name: Optional[str] = None,
+    premerge_order: float = 0.0,
+    postmerge_order: float = 0.0,
+    endbuild_order: float = 0.0,
+) -> Processing:
+    """Create a processing object that keep a property from a value using tag or regex.
+
+    The pre-merge processing looks for keys that match the tag or the regex, apply
+    the function func on the value and store the result (= the "property"):
+    `property = func(flat_dict[key])`.
+    The post-merge processing will check that the property is the same as the one
+    stored during pre-merge. If not, it will raise a ValueError.
+
+    It also possible to pass the flat config as a second argument of the function
+    `func`. In this case, the function apply
+    `property = func(flat_dict[key], flat_config)`.
+
+    Parameters
+    ----------
+    func : Callable
+        The function to apply to the value (and eventually the flat config)
+        to define the property to keep.
+        property = func(flat_dict[key]) or func(flat_dict[key], flat_config)
+    regex : Optional[str]
+        The regex to match the key.
+    tag_name : Optional[str]
+        The tag (without "@") to match the key. The values are modified when
+        triggering the pattern ".*@<tag_name>.*" and the tag is removed from the key.
+    premerge_order : float, optional
+        The pre-merge order, by default 0.0
+    postmerge_order : float, optional
+        The post-merge order, by default 0.0
+    endbuild_order : float, optional
+        The end-build order, by default 0.0
+
+    Raises
+    ------
+    ValueError
+        If both tag and regex are provided or if none of them are provided.
+
+    Returns
+    -------
+    Processing
+        The processing object with the pre-merge and post-merge methods.
+
+    Examples
+    --------
+    A processing that enforce the types of all the parameters to be constant
+    (equal to the type of the first value encountered):
+
+    ```python
+    create_processing_keep_property(
+        type,
+        regex=".*",
+        premerge_order=15.0,
+        postmerge_order=15.0,
+        endbuild_order=15.0
+    )
+    ```
+
+    A processing that protect parameters tagged with @protect from being changed:
+
+    ```python
+    create_processing_keep_property(
+        lambda x: x,
+        tag_name="protect",
+        premerge_order=15.0,
+        postmerge_order=15.0
+    )
+    ```
+    """
+    if tag_name is not None:
+        if regex is not None:
+            raise ValueError("You must provide a tag or a regex but not both.")
+    else:
+        if regex is None:
+            raise ValueError(
+                "You must provide a tag or a regex (to trigger the value update)."
+            )
+    processing = _ProcessingKeepProperty(
+        func,
+        regex=regex,
+        tag_name=tag_name,
+        premerge_order=premerge_order,
+        postmerge_order=postmerge_order,
+        endbuild_order=endbuild_order,
+    )
+    return processing
 
 
 class _ProcessingValue(Processing):
@@ -226,202 +432,15 @@ def __repr__(self) -> str:
         )
 
 
-def create_processing_value(
-    func: Union[Callable[[Any], Any], Callable[[Any, Config], Any]],
-    processing_type: str = "premerge",
-    *,
-    regex: Optional[str] = None,
-    tag_name: Optional[str] = None,
-    order: float = 0.0,
-    persistent: bool = False,
-) -> Processing:
-    r"""Create a processing object that modifies a value in config using tag or regex.
-
-    The processing is applied on pre-merge. It triggers when the key matches
-    the tag or the regex. The function apply ``flat_dict[key] = func(flat_dict[key])``.
-    You must only provide one of tag or regex. If tag is provided, the tag will be
-    removed from the key during pre-merge.
-
-    It also possible to pass the flat config as a second argument of the function
-    ``func``. In this case, the function apply
-    ``flat_dict[key] = func(flat_dict[key], flat_config)``.
-
-    Parameters
-    ----------
-    func : Callable
-        The function to apply to the value (and eventually the flat config)
-        to make the new value so that:
-        flat_dict[key] = func(flat_dict[key]) or func(flat_dict[key], flat_config)
-    processing_type : str, optional
-        One of "premerge", "postmerge", "presave", "postload" or "endbuild".
-        Timing to apply the value update. In all cases the tag is removed on pre-merge.
-        By default "premerge".
-    regex : Optional[str]
-        The regex to match the key.
-    tag_name : Optional[str]
-        The tag (without "@") to match the key. The tag is removed on pre-merge.
-    order : int, optional
-        The pre-merge order. By default 0.0.
-    persistent : bool, optional
-        If True, the processing will be applied on all keys that have already
-        matched the tag before. By nature, using regex make the processing
-        always persistent. By default, False.
-
-    Raises
-    ------
-    ValueError
-        If both tag and regex are provided or if none of them are provided.
-    ValueError
-        If the processing type is not one of "premerge", "postmerge", "presave",
-        "postload" or "endbuild".
-
-    Returns
-    -------
-    processing : Processing
-        The processing object with the pre-merge method.
-
-
-    Examples
-    --------
-    With the following config and 2 processings:
-
-    .. code_block: yaml
-
-        # config.yaml
-        neg_number1: 1
-        neg_number2: 1
-        neg_number3@add1: 1
-
-    ::
-
-        proc2 = create_processing_value(lambda val: -val, regex="neg_number.*",
-            order=0.0)
-        proc1 = create_processing_value(lambda val: val + 1, tag_name="add1",
-            order=1.0)
-
-    When config.yaml is merged with an other config, it will be considered
-    before merging as:
-
-    ::
-
-        {'number1': -1, 'number2': -1, 'number3': 0}
-
-    Using the config as a second argument of the function:
-
-    .. code_block: yaml
-
-        # config.yaml
-        param1: 1
-        param2@eval: "config.param1 + 1"
-
-    ::
-
-        proc = create_processing_value(
-            lambda val, config: eval(val, {'config': config}),
-            processing_type='postmerge', tag_name='eval', persistent=False
-        )
-
-    After config.yaml is merged with another config, param2 will be evaluated
-    as 2 (except if config.param1 has changed with a processing before).
-    """
-    if tag_name is not None:
-        if regex is not None:
-            raise ValueError("You must provide a tag or a regex but not both.")
-    else:
-        if regex is None:
-            raise ValueError(
-                "You must provide a tag or a regex (to trigger the value update)."
-            )
-    proc = _ProcessingValue(
-        func,
-        processing_type,
-        regex=regex,
-        tag_name=tag_name,
-        order=order,
-        persistent=persistent,
-    )
-    return proc
-
-
-def create_processing_keep_property(
-    func: Callable,
-    regex: Optional[str] = None,
-    tag_name: Optional[str] = None,
-    premerge_order: float = 0.0,
-    postmerge_order: float = 0.0,
-    endbuild_order: float = 0.0,
-) -> Processing:
-    """Create a processing object that keep a property from a value using tag or regex.
-
-    The pre-merge processing looks for keys that match the tag or the regex, apply
-    the function func on the value and store the result (= the "property"):
-    ``property = func(flat_dict[key])``.
-    The post-merge processing will check that the property is the same as the one
-    stored during pre-merge. If not, it will raise a ValueError.
-
-    It also possible to pass the flat config as a second argument of the function
-    ``func``. In this case, the function apply
-    ``property = func(flat_dict[key], flat_config)``.
-
-    Parameters
-    ----------
-    func : Callable
-        The function to apply to the value (and eventually the flat config)
-        to define the property to keep.
-        property = func(flat_dict[key]) or func(flat_dict[key], flat_config)
-    regex : Optional[str]
-        The regex to match the key.
-    tag_name : Optional[str]
-        The tag (without "@") to match the key. The values are modified when
-        triggering the pattern ".*@<tag_name>.*" and the tag is removed from the key.
-    premerge_order : float, optional
-        The pre-merge order, by default 0.0
-    postmerge_order : float, optional
-        The post-merge order, by default 0.0
-    endbuild_order : float, optional
-        The end-build order, by default 0.0
-
-    Raises
-    ------
-    ValueError
-        If both tag and regex are provided or if none of them are provided.
-
-    Returns
-    -------
-    Processing
-        The processing object with the pre-merge and post-merge methods.
-
-    Examples
-    --------
-    A processing that enforce the types of all the parameters to be constant
-    (equal to the type of the first value encountered):
-
-    ::
-
-        create_processing_keep_property(type, regex=".*", premerge_order=15.0,
-                                        postmerge_order=15.0, endbuild_order=15.0)
-
-    A processing that protect parameters tagged with @protect from being changed:
-
-    ::
-
-        create_processing_keep_property(lambda x: x, tag_name="protect",
-                                        premerge_order=15.0, postmerge_order=15.0)
-    """
+def _is_matched(key: str, tag_name: Optional[str], regex: Optional[str]) -> bool:
+    """Check if key match the regex or contain the tag."""
+    if tag_name is not None and regex is not None:
+        raise ValueError("Either regex or tag_name must be defined but not both.")
+    # Case defined with tag
     if tag_name is not None:
-        if regex is not None:
-            raise ValueError("You must provide a tag or a regex but not both.")
-    else:
-        if regex is None:
-            raise ValueError(
-                "You must provide a tag or a regex (to trigger the value update)."
-            )
-    processing = _ProcessingKeepProperty(
-        func,
-        regex=regex,
-        tag_name=tag_name,
-        premerge_order=premerge_order,
-        postmerge_order=postmerge_order,
-        endbuild_order=endbuild_order,
-    )
-    return processing
+        return is_tag_in(key, tag_name)
+    # Case defined with regex
+    if regex is not None:
+        param_name = key.split(".")[-1].split("@")[0]
+        return re.match(regex, param_name) is not None
+    raise ValueError("Either regex or tag_name must be defined.")
diff --git a/cliconfig/tag_routines.py b/cliconfig/tag_routines.py
index 342ba14..4210243 100644
--- a/cliconfig/tag_routines.py
+++ b/cliconfig/tag_routines.py
@@ -1,7 +1,7 @@
 # Copyright (c) 2023 Valentin Goldite. All Rights Reserved.
 """Routines to manipulate the tags on the keys of a dict.
 
-Used by the processing functions.
+Used by the processing objects.
 """
 import copy
 import re
@@ -27,14 +27,14 @@ def clean_tag(flat_key: str, tag_name: str) -> str:
 
     Note
     ----
-        ``tag_name`` is supposed to be the exact name of the tag.
+    `tag_name` is supposed to be the exact name of the tag.
 
     Examples
     --------
-    ::
-
-        >>> clean_tag('abc@tag.def@tag_2.ghi@tag', 'tag')
-        abc.def@tag_2.ghi
+    ```python
+    >>> clean_tag('abc@tag.def@tag_2.ghi@tag', 'tag')
+    abc.def@tag_2.ghi
+    ```
     """
     if tag_name[0] == "@":
         tag_name = tag_name[1:]
diff --git a/docs/Makefile b/docs/Makefile
deleted file mode 100644
index e8dec73..0000000
--- a/docs/Makefile
+++ /dev/null
@@ -1,20 +0,0 @@
-# Minimal makefile for Sphinx documentation
-#
-
-# You can set these variables from the command line, and also
-# from the environment for the first two.
-SPHINXOPTS    ?=
-SPHINXBUILD   ?= sphinx-build
-SOURCEDIR     = ../docs
-BUILDDIR      = _build
-
-# Put it first so that "make" without argument is like "make help".
-help:
-	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
-
-.PHONY: help Makefile
-
-# Catch-all target: route all unknown targets to Sphinx using the new
-# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
-%: Makefile
-	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/README_pypi.rst b/docs/README_pypi.rst
deleted file mode 100644
index 797ac12..0000000
--- a/docs/README_pypi.rst
+++ /dev/null
@@ -1,170 +0,0 @@
-CLI Config
-==========
-
-*Build your experiment configurations for complex projects with robustness, flexibility and simplicity*
-
-|
-
-.. image:: https://github.com/valentingol/cliconfig/blob/main/docs/_static/logo_extend.png
-  :align: center
-  :width: 1000
-  :alt: CLI config logo
-
-|
-
-*CLI Config*: Lightweight library that provides routines to merge nested configs
-and set parameters from command line. It is also provide processing functions
-that can change the whole configuration before and after each config merge, config
-saving, config loading and at the end of config building. It also contains many
-routines to manipulate the config as flatten or nested dictionaries.
-
-The package is initially designed for machine learning experiments where the
-number of parameters is huge and a lot of them have to be set by the user between
-each experiment. If your project matches this description, this package is for you!
-
-`Documentation <https://cliconfig.readthedocs.io/en/latest/>`_
-
-`Github project <https://github.com/valentingol/cliconfig>`_
-
-
-|PyPI version| |PythonVersion| |License| |Waka_Time|
-
-|Ruff_logo| |Black_logo|
-
-|Ruff| |Flake8| |Pydocstyle| |MyPy| |PyLint|
-
-|Tests| |Coverage| |Documentation Status|
-
-Official badge:
-|Config|
-
-Make default config yaml file(s) in your project (configs are merged from left to right):
-
-.. code:: python
-
-   # main.py
-   from cliconfig import make_config
-
-   config = make_config('default1.yaml', 'default2.yaml')
-
-Then launch your script with additional config(s) file(s) and parameters by command line.
-The additional configs are merged on the default one's then the parameters are set.
-
-.. code:: bash
-
-   python main.py --config first.yaml,second.yaml --param1=1 --subconfig.param2='foo'
-
-**By default, these additional configs cannot add new parameters to the default config
-(for security and retro-compatibility reasons).**
-
-Now you can get your configuration parameters in your script:
-
-.. code:: python
-
-   # Nested config dict as a native python dict
-   config.dict
-   # Get a parameter value (you can also set it or delete it)
-   config.foo1.foo2.bar
-
-You can also load and save configs with `cliconfig.save_config` and `cliconfig.load_config`.
-
-With processing
----------------
-
-The library provides powerful tools to modify the configuration called "processings".
-One of the possibility they add is to merge multiple configurations,
-copy a parameter on another, enforce type and more. To do so, simply adding the
-corresponding tags to your parameter names (on config files or CLI parameters).
-
-For instance with these config files:
-
-.. code:: yaml
-
-    # main.yaml
-    path_1@merge_add: sub1.yaml
-    path_2@merge_add: sub2.yaml
-    config3.selection@delete@select: config3.param1
-
-    # sub1.yaml
-    config1:
-      param@copy@type:int: config2.param
-      param2@type:float: 1  # wrong type -> converted to float
-
-    # sub2.yaml
-    config2.param: 2
-    config3:
-      param1@def: "[(config1.param2 + config2.param) / 2] * 2 if config2.param else None"
-      param2: 3
-    my_dict@dict:
-      key1: 1
-      key2: 2
-
-Note that can also use YAML tags separated with "@" (like `key: !tag@tag2 value`)
-to add tags instead of putting them in the parameter name (like `key@tag@tag2: value`).
-
-Here `main.yaml` will be interpreted like:
-
-.. code:: yaml
-
-    path_1: sub1.yaml
-    path_2: sub2.yaml
-    config1:
-      param: 2  # the value of config2.param
-      param2: 1.0  # converted to float
-    config2:
-      param: 2
-    config3:
-      param1: [1.5, 1.5]
-      # param2 is deleted because it is not in the selection
-    my_dict: {key1: 1, key2: 2}  # (changing the whole dict further is allowed)
-
-Then, all the parameters in `config1` have enforced types, changing
-`config2.param` will also update `config1.param` accordingly (which is
-protected by direct update). Finally, changing `config1.param2` or `config2.param`
-will update `config3.param1` accordingly until a new value is set for `config3.param1`.
-
-The default tags include:
-
-* `@merge_add`, `@merge_before`, and `@merge_after`: merge other yaml files
-  indicating the path
-* `@copy`: copy an other parameter
-* `@def`: safely evaluate an expression to define the parameter value
-* `@type:<my type>`: try to convert and check the type
-* `@select`: target a sub-config and delete the sub-config in the parent config
-* `@delete`: delete the key from the config
-* `@new`: allow to add parameters that are not present in default config
-* `@dict`: create a dict object that is not a sub-config (see the
-  *Edge cases* section)
-
-See *Quickstart* section of the documentation for more details and
-*Processing* section for advanced usage.
-
-.. |PyPI version| image:: https://img.shields.io/github/v/tag/valentingol/cliconfig?label=Pypi&logo=pypi&logoColor=yellow
-   :target: https://pypi.org/project/cliconfig/
-.. |PythonVersion| image:: https://img.shields.io/badge/Python-3.7%20%7E%203.11-informational
-.. |License| image:: https://img.shields.io/github/license/valentingol/cliconfig?color=999
-   :target: https://stringfixer.com/fr/MIT_license
-.. |Waka_Time| image:: https://wakatime.com/badge/github/valentingol/cliconfig.svg
-    :target: https://wakatime.com/badge/github/valentingol/cliconfig
-.. |Ruff_logo| image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json
-   :target: https://github.com/charliermarsh/ruff
-.. |Black_logo| image:: https://img.shields.io/badge/code%20style-black-000000.svg
-   :target: https://github.com/psf/black
-.. |Ruff| image:: https://github.com/valentingol/cliconfig/actions/workflows/ruff.yaml/badge.svg
-   :target: https://github.com/valentingol/cliconfig/actions/workflows/ruff.yaml
-.. |Flake8| image:: https://github.com/valentingol/cliconfig/actions/workflows/flake.yaml/badge.svg
-   :target: https://github.com/valentingol/cliconfig/actions/workflows/flake.yaml
-.. |Pydocstyle| image:: https://github.com/valentingol/cliconfig/actions/workflows/pydocstyle.yaml/badge.svg
-   :target: https://github.com/valentingol/cliconfig/actions/workflows/pydocstyle.yaml
-.. |MyPy| image:: https://github.com/valentingol/cliconfig/actions/workflows/mypy.yaml/badge.svg
-   :target: https://github.com/valentingol/cliconfig/actions/workflows/mypy.yaml
-.. |PyLint| image:: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/valentingol/ab12676c87f0eaa715bef0f8ad31a604/raw/cliconfig_pylint.json
-   :target: https://github.com/valentingol/cliconfig/actions/workflows/pylint.yaml
-.. |Tests| image:: https://github.com/valentingol/cliconfig/actions/workflows/tests.yaml/badge.svg
-   :target: https://github.com/valentingol/cliconfig/actions/workflows/tests.yaml
-.. |Coverage| image:: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/valentingol/098e9c7c53be88779ee52ef2f2bc8803/raw/cliconfig_tests.json
-   :target: https://github.com/valentingol/cliconfig/actions/workflows/tests.yaml
-.. |Documentation Status| image:: https://readthedocs.org/projects/cliconfig/badge/?version=latest
-   :target: https://cliconfig.readthedocs.io/en/latest/?badge=latest
-.. |Config| image:: https://custom-icon-badges.demolab.com/badge/cliconfig-black?logo=cliconfig
-    :target: https://github.com/valentingol/cliconfig
diff --git a/docs/_static/logo.png b/docs/_static/logo.png
index 363dfde..73f5ef2 100644
Binary files a/docs/_static/logo.png and b/docs/_static/logo.png differ
diff --git a/docs/_static/logo_black.png b/docs/_static/logo_black.png
new file mode 100644
index 0000000..363dfde
Binary files /dev/null and b/docs/_static/logo_black.png differ
diff --git a/docs/_static/logo_extend.png b/docs/_static/logo_extend.png
index c61b1c3..a047308 100644
Binary files a/docs/_static/logo_extend.png and b/docs/_static/logo_extend.png differ
diff --git a/docs/_static/logo_extend_black.png b/docs/_static/logo_extend_black.png
new file mode 100644
index 0000000..c61b1c3
Binary files /dev/null and b/docs/_static/logo_extend_black.png differ
diff --git a/docs/api/base.rst b/docs/api/base.rst
deleted file mode 100644
index d3f7389..0000000
--- a/docs/api/base.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-cliconfig.base
---------------
-
-.. automodule:: cliconfig.base
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/api/cli_parser.rst b/docs/api/cli_parser.rst
deleted file mode 100644
index 1b08f68..0000000
--- a/docs/api/cli_parser.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-cliconfig.cli\_parser
----------------------
-
-.. automodule:: cliconfig.cli_parser
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/api/config_routines.rst b/docs/api/config_routines.rst
deleted file mode 100644
index 4d64c54..0000000
--- a/docs/api/config_routines.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-cliconfig.config\_routines
---------------------------
-
-.. automodule:: cliconfig.config_routines
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/api/dict_routines.rst b/docs/api/dict_routines.rst
deleted file mode 100644
index 5d633e7..0000000
--- a/docs/api/dict_routines.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-cliconfig.dict\_routines
-------------------------
-
-.. automodule:: cliconfig.dict_routines
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/api/main.rst b/docs/api/main.rst
deleted file mode 100644
index 9e8096e..0000000
--- a/docs/api/main.rst
+++ /dev/null
@@ -1,27 +0,0 @@
-CLI Config API
-==============
-
-The submodules are listed below. For a short summary:
-
-* :mod:`config_routines` - routines for creating, loading, saving, updating, copying,
-  ... configuration files (using YAML files and CLI if needed)
-* :mod:`processing` - processing classes to bring more features to the
-  configuration building process. Contains the default processings (@merge_add, @copy, ...)
-* :mod:`process_routines` - operation functions on configuration that trigger processings
-* :mod:`dict_routines` - operation functions on python native dictionaries
-* :mod:`tag_routines` - operation functions on tags names (like `param@tag@tag2`).
-* :mod:`cli_parser` - CLI parser for building configuration from command line arguments
-* :mod:`base` - base class of configuration objects
-
-Contents:
-
-.. toctree::
-   :maxdepth: 3
-
-   config_routines
-   processing/main
-   process_routines
-   dict_routines
-   tag_routines
-   cli_parser
-   base
diff --git a/docs/api/process_routines.rst b/docs/api/process_routines.rst
deleted file mode 100644
index 3db8bb0..0000000
--- a/docs/api/process_routines.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-cliconfig.process\_routines
----------------------------
-
-.. automodule:: cliconfig.process_routines
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/api/processing/base.rst b/docs/api/processing/base.rst
deleted file mode 100644
index 83262d3..0000000
--- a/docs/api/processing/base.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-cliconfig.processing.base
--------------------------
-
-.. automodule:: cliconfig.processing.base
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/api/processing/builtin.rst b/docs/api/processing/builtin.rst
deleted file mode 100644
index df0891b..0000000
--- a/docs/api/processing/builtin.rst
+++ /dev/null
@@ -1,6 +0,0 @@
-cliconfig.processing.builtin
-----------------------------
-
-.. automodule:: cliconfig.processing.builtin
-   :members:
-   :undoc-members:
diff --git a/docs/api/processing/create.rst b/docs/api/processing/create.rst
deleted file mode 100644
index ee2124f..0000000
--- a/docs/api/processing/create.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-cliconfig.processing.create
----------------------------
-
-.. automodule:: cliconfig.processing.create
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/api/processing/main.rst b/docs/api/processing/main.rst
deleted file mode 100644
index 3acaf96..0000000
--- a/docs/api/processing/main.rst
+++ /dev/null
@@ -1,9 +0,0 @@
-cliconfig.processing
-====================
-
-.. toctree::
-   :maxdepth: 2
-
-   create
-   builtin
-   base
diff --git a/docs/api/tag_routines.rst b/docs/api/tag_routines.rst
deleted file mode 100644
index c37c6cc..0000000
--- a/docs/api/tag_routines.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-cliconfig.tag\_routines
------------------------
-
-.. automodule:: cliconfig.tag_routines
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/conf.py b/docs/conf.py
deleted file mode 100644
index 695b1e3..0000000
--- a/docs/conf.py
+++ /dev/null
@@ -1,83 +0,0 @@
-# Copyright (c) 2023 Valentin Goldite. All Rights Reserved.
-"""Configuration file for the Sphinx documentation builder."""
-# pylint: disable=all
-# For the full list of built-in configuration values, see the documentation:
-# https://www.sphinx-doc.org/en/master/usage/configuration.html
-import os
-import sys
-
-from setuptools_scm import get_version
-
-sys.path.insert(0, os.path.abspath("../"))
-
-# -- Project information -----------------------------------------------------
-# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
-
-project = "CLI Config"
-copyright = "2023, Valentin Goldite"  # noqa A001
-author = "Valentin Goldite"
-try:
-    release = get_version()
-except:  # noqa E722
-    release = get_version(root="..", relative_to=__file__)
-
-# -- General configuration ---------------------------------------------------
-# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
-
-extensions = [
-    "myst_parser",
-    "sphinx.ext.autodoc",
-    "sphinx.ext.inheritance_diagram",
-    "sphinx.ext.intersphinx",
-    "sphinx.ext.napoleon",
-    "sphinx_autodoc_typehints",
-]
-
-master_doc = "index"
-autoapi_type = "python"
-autoapi_dirs = ["cliconfig"]
-
-autodoc_default_options = {
-    "member-order": "bysource",
-    "undoc-members": True,
-}
-
-add_module_names = False
-autoclass_content = "both"
-napoleon_use_param = True
-
-intersphinx_mapping = {
-    "python": ("https://docs.python.org/", None),
-    "numpy": ("http://docs.scipy.org/doc/numpy/", None),
-}
-
-templates_path = ["_templates"]
-exclude_patterns = ["_build"]
-
-# -- Options for HTML output -------------------------------------------------
-# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
-
-html_theme = "sphinx_rtd_theme"
-
-html_theme_options = {
-    "canonical_url": "",
-    "analytics_id": "UA-XXXXXXX-1",
-    "logo_only": False,
-    "display_version": True,
-    "prev_next_buttons_location": "both",
-    "style_external_links": "#ff9900",
-    "style_nav_header_background": "#ff9900",
-    # Toc options
-    "collapse_navigation": False,
-    "sticky_navigation": True,
-    "navigation_depth": 4,
-    "includehidden": True,
-    "titles_only": False,
-}
-html_context = {
-    "display_github": True,  # Integrate GitHub
-    "github_user": "valentingol",  # Username
-    "github_repo": "cliconfig",  # Repo name
-    "github_version": "main",  # Version
-    "conf_py_path": "/docs/",  # Path in the checkout to the docs root
-}
diff --git a/docs/contribute.md b/docs/contribute.md
deleted file mode 100644
index edd321d..0000000
--- a/docs/contribute.md
+++ /dev/null
@@ -1,12 +0,0 @@
-# How to contribute
-
-For **development**, install the package dynamically and dev requirements with:
-
-```bash
-pip install -e .
-pip install -r requirements-dev.txt
-```
-
-Everyone can contribute to CLI Config, and we value everyone’s contributions. Please see our
-[contributing guidelines](https://github.com/valentingol/cliconfig/blob/main/CONTRIBUTING.md)
-for more information 🤗
diff --git a/docs/create_config.md b/docs/create_config.md
deleted file mode 100644
index ce3d212..0000000
--- a/docs/create_config.md
+++ /dev/null
@@ -1,82 +0,0 @@
-# Alternative ways to create a config
-
-## From a python dict
-
-```python
-from cliconfig import Config
-my_dict = {'param1': 1, 'param2': 2}
-config = Config(my_dict)
-```
-
-You can also add built-in or custom processings:
-
-```python
-from cliconfig import Config, create_processing_value
-from cliconfig.processing.builtin import ProcessCopy
-my_dict = {'param1': 1, 'param2': 2}
-my_proc = create_processing_value(lambda x: x+1, "premerge", tag_name='add1')
-config = Config(my_dict, [my_proc, ProcessCopy()])
-```
-
-## From a yaml file without command line arguments (useful for notebooks)
-
-```python
-
-from cliconfig import make_config
-config = make_config('my_yaml_file.yaml', no_cli=True)
-```
-
-You can merge multiple yaml files that will be considered as default configs
-(new parameter names are allowed).
-
-```python
-from cliconfig import make_config
-config = make_config('config1.yaml', 'config2.yaml', no_cli=True)
-```
-
-You can also pass a list of processing objects like usual.
-
-## From a config (make a copy)
-
-```python
-from cliconfig.config_routines import copy_config
-config2 = copy_config(config)
-```
-
-## From two dicts (or configs) to merge one into the other
-
-```python
-from cliconfig import Config, update_config
-new_config = update_config(Config(config1), config2)  # if config1 is a dict
-new_config = update_config(config1, config2)  # if config1 is a Config
-```
-
-These two lines work whether the config2 is a dict or a Config.
-Note that the second config will override the first one.
-
-## From a list of arguments
-
-Assuming the arguments are under the format
-`['--key1=value1', '--key2.key3=value2']`:
-
-```python
-from cliconfig import Config, unflatten_config
-from cliconfig.cli_parser import parse_cli
-my_args = ['--key1=value1', '--key2.key3=value2']
-config = Config(parse_cli(my_args)[0])  # flat
-config = unflatten_config(config)
-```
-
-## From a yaml formatted string
-
-```python
-from yaml import safe_load
-from cliconfig import Config, unflatten_config
-yaml_txt = """
-a:
-  d: [2, 3]
-  b.c: {d: 4, e: 5}
-"""
-config = Config(safe_load(yaml_txt))  # mix flat and nested
-config = unflatten_config(config)
-```
diff --git a/docs/edge_cases.md b/docs/edge_cases.md
deleted file mode 100644
index e1a0e51..0000000
--- a/docs/edge_cases.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# Edge cases
-
-**Please note that YAML does not support tuples and sets**, and therefore they
-cannot be used in YAML files. If possible, consider using lists instead.
-
-Moreover, YAML does not recognize "None" as a None object, but interprets it as a
-string. If you wish to set a None object, you can use "null" or "Null" instead.
-
-"@" is a special character used by the package to identify tags. You can't use it
-in your parameters names (but you can use it in your values). It will raise an error
-if you try to do so.
-
-"dict" and "process_list" are reserved names of attributes and should not be used
-as sub-config or parameter names. It can raise an error if you try to access them
-as config attributes (with dots).
-
-In the context of this package, dictionaries are treated as sub-configurations,
-which means that modifying or adding keys directly in the additional configs may
-not be possible (because only the merge of default configuration allow adding new keys).
-If you need to have a dictionary object where you can modify the keys, consider
-using the `@dict` tag:
-
-For instance:
-
-```yaml
-# default.yaml
-logging:
-  metrics: [train loss, val loss]
-  styles@dict: {train_loss: red, val_loss: blue}
-# additional.yaml
-logging:
-  metrics: [train loss, val acc]
-  styles@dict: {train_loss: red, val_acc: cyan}
-```
-
-Like a sub-config, the dictionary can be accessed with the dot notation like this:
-`config.logging.styles.val_acc` and will return "cyan".
diff --git a/docs/index.rst b/docs/index.rst
deleted file mode 100644
index e1f55f4..0000000
--- a/docs/index.rst
+++ /dev/null
@@ -1,15 +0,0 @@
-.. include:: README_pypi.rst
-
-.. toctree::
-   :maxdepth: 2
-   :caption: Contents:
-
-   installation
-   quickstart
-   edge_cases
-   processing
-   create_config
-   sweep
-   api/main
-   contribute
-   license
diff --git a/docs/installation.md b/docs/installation.md
deleted file mode 100644
index 7d22f94..0000000
--- a/docs/installation.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Installation
-
-In a new virtual environment, simply install the package via
-[pypi](https://pypi.org/project/cliconfig/):
-
-```bash
-pip install cliconfig
-```
-
-This package is OS independent and supported on Linux, macOS and Windows.
diff --git a/docs/license.md b/docs/license.md
deleted file mode 100644
index 332161c..0000000
--- a/docs/license.md
+++ /dev/null
@@ -1,13 +0,0 @@
-# License
-
-Copyright © 2023 Valentin Goldité
-
-This program is free software: you can redistribute it and/or modify it
-under the terms of the `MIT License <LICENSE>`__. This program is
-distributed in the hope that it will be useful, but WITHOUT ANY
-WARRANTY; without even the implied warranty of MERCHANTABILITY or
-FITNESS FOR A PARTICULAR PURPOSE.
-
-The project is free to use for COMMERCIAL USE, MODIFICATION,
-DISTRIBUTION and PRIVATE USE as long as the original license is included
-as well as this copy right notice at the top of the modified files.
diff --git a/docs/make.bat b/docs/make.bat
deleted file mode 100644
index dc2eb3d..0000000
--- a/docs/make.bat
+++ /dev/null
@@ -1,35 +0,0 @@
-@ECHO OFF
-
-pushd %~dp0
-
-REM Command file for Sphinx documentation
-
-if "%SPHINXBUILD%" == "" (
-	set SPHINXBUILD=sphinx-build
-)
-set SOURCEDIR=../docs
-set BUILDDIR=_build
-
-if "%1" == "" goto help
-
-%SPHINXBUILD% >NUL 2>NUL
-if errorlevel 9009 (
-	echo.
-	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
-	echo.installed, then set the SPHINXBUILD environment variable to point
-	echo.to the full path of the 'sphinx-build' executable. Alternatively you
-	echo.may add the Sphinx directory to PATH.
-	echo.
-	echo.If you don't have Sphinx installed, grab it from
-	echo.http://sphinx-doc.org/
-	exit /b 1
-)
-
-%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
-goto end
-
-:help
-%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
-
-:end
-popd
diff --git a/docs/processing.md b/docs/processing.md
deleted file mode 100644
index 444a9fa..0000000
--- a/docs/processing.md
+++ /dev/null
@@ -1,385 +0,0 @@
-# Processing
-
-Processings are powerful tools to modify the config at each step of the lifecycle of
-a configuration. More precisely, you can use processings to modify the full
-configuration before and after each merge, after loading, and before saving the config.
-
-The processings are applied via a processing object that have five methods
-(called "processing" to simplify): `premerge`, `postmerge`, `endbuild`, `postload`
-and `presave`. These names correspond to the timing they are applied. Each processing
-has the signature:
-
-```python
-def premerge(self, flat_config: Config) -> Config:
-    ...
-    return flat_config
-```
-
-Where `Config` is a simple class containing only two attributes (and no methods):
-`dict` that is the configuration dict and `process_list`, the list of processing objects
-(we discuss this in a section below). Note that it is
-also the class of the object returned by the `make_config` function.
-
-They only take a flat config as input i.e a config containing a dict of depth 1 with
-dot-separated keys and return the modified flat dict (and keep it flat!).
-
-In this section, you will learn how they work and how to create your own to make
-whatever you want with the config (we hope!).
-
-## Why a flat dict?
-
-The idea is that when we construct a config, we manipulate dictionaries that contain
-both nested sub-dictionaries and flat keys simultaneously. To simplify this process, the
-dictionaries are systematically flattened before merging. This approach makes things
-simpler and prevents duplicated keys within the same configuration, as shown in the
-example:
-
-```python
-config = {'a': {'b': 1}, 'a.b': 2}
-```
-
-More generally, all config modifications are performed using flat dictionaries
-during config construction, and the same applies to processings. For processings,
-it is even more interesting as you can have access to the full sub-config names
-to make your processing if needed.
-
-However, it's important to note that after building your config with `make_config`,
-the dict will be unflattened to its normal nested configuration structure.
-
-## Processing order
-
-The order in which the processings are triggered is crucial because they modify
-the config and consequently affect the behavior of subsequent processings.
-To manage this order, the processing class have five float attributes representing
-the order of the five processing methods: premerge, postmerge, endbuild, postload,
-and presave.
-
-Here's a basic example to illustrate the significance of the order:
-
-```yaml
-# config1.yaml
-merge@merge_add@delete: config2.yaml
-param: 1
-# config2.yaml
-param2: 2
-```
-
-In this example, we want to build a global config using `config1.yaml`. This file contains only
-half of the parameters, and the other half is in `config2.yaml`. Then, we add a key
-with the name of our choice, here "merge", tagged with `@merge_add` to merge
-`config2.yaml` before the global config update. We add the `@delete` tag to delete
-the key "merge" before merging with the global config because in this case, there is
-no key with the name "merge" in the global config, and it would raise an error as
-it is not possible to ass new keys.
-
-`@merge_add` and `@delete` has both only a pre-merge effect. Let's check the orders.
-It is `-20.0` for merge and `30.0` for delete. So merge trigger first, add `param2` and
-the "merge" key is deleted after it. If the orders were reversed, the key would have been
-deleted before merge processing and so the `param2` would not have been updated with the
-value of 2 and the resulting configuration would potentially not have been
-the expected one at all.
-
-Therefore, it is crucial to carefully manage the order when creating your own
-processings!
-
-Some useful ranges to choose your order:
-
-* not important: order = 0 (default)
-* if it checks/modifies the config before applying any processing: order < -25
-* if it adds new parameters: -25 < order < -5
-* if it updates a value based on itself: -5 < order < 5
-* if it updates a value based on other keys: 5 < order < 15
-* if it checkss a property on a value: 15 < order < 25
-* if it deletes other key(s) but you want to trigger the tags before: 25 < order < 35
-* final check/modification after all processings: order > 35
-
-Note: a pre-merge order should not be greater than 1000, the order of the default
-processing `ProcessCheckTags` that raise an error if tags still exist at the end
-of the pre-merge step.
-
-## Create basic processing
-
-### Processing that modify a single value
-
-One of the most useful kind of processing look for parameters which names match a certain
-pattern (e.g a prefix or a suffix) or contain a specific tag and modify their values
-depending on their current ones.
-
-To simplify the creation of such a process, we provide the `cliconfig.create_processing_value` function.
-This function allows you to quickly create a processing that matches a regular
-expression or a specific tag name (in which case the tag is removed after pre-merging).
-You specify the function to be applied on the value to modify it, and optionally,
-the order of the processing. Additionally, there is a `persistent` argument, which is
-a boolean value indicating whether encountering the tag (if a tag is used) once in
-a parameter name will continue to trigger the processing for this parameter
-even after the tag is removed. By default, it is `False`. Finally, you can set
-the processing type (pre-merge, post-merge, etc.) at your convenience. Default is pre-merge.
-
-Here's an example to illustrate:
-
-```python
-proc = create_processing_value(lambda x: str(x), 'premerge', tag_name='convert_str', persistent=True)
-config = make_config(default_config, process_list=[proc])
-```
-
-In this example, the config `{"subconfig.param@convert_str": 1}` will
-be converted to `{"subconfig.param": "1"}`. Moreover, the keys `subconfig.param`
-will be permanently converted to strings before every merge.
-
-It's worth noting that you can also use functions that have side effects without
-necessarily changing the value itself. For example, you can use a function to
-check if a certain condition is met by the value.
-
-It is also possible to pass the flat config as a second argument to the function.
-For example:
-
-```yaml
-# config.yaml
-param: 1
-param2@eval: "config.param + 1"
-```
-
-```python
-proc = create_processing_value(
-    lambda x, config: eval(x, {"config": config}),
-    tag_name="eval",
-    persistent=False,
-)
-# (Note that the `eval` function is not safe and the code above
-# should not be used in case of untrusted config)
-```
-
-Here the value of `param2` will be evaluated to 2 at pre-merge step.
-
-### Pre-merge/post-merge processing that protect a property from being modified
-
-Another useful kind of processing is a processing that ensure to keep a certain
-property on the value. For this kind of processing, you can use
-`cliconfig.create_processing_keep_property`. It takes a function that returns
-the property from the value, the regex or the tag name like the previous function,
-and the order of the pre-merge and the post-merge.
-
-The pre-merge processing looks for keys that match the tag or the regex, apply
-the function on the value and store the result (= the "property").
-The post-merge and end-build processing will check that the property is the same as
-the one stored during pre-merge. If not, it will raise an error.
-
-Examples:
-
-A processing that enforce the types of all the parameters to be constant
-(equal to the type of the first value encountered):
-
-```python
-create_processing_keep_property(type, regex=".*", premerge_order=15.0,
-                                postmerge_order=15.0, endbuild_order=15.0)
-```
-
-A processing that protect parameters tagged with @protect from being changed:
-
-```python
-create_processing_keep_property(lambda x: x, tag_name="protect",
-                                premerge_order=15.0, postmerge_order=15.0,
-                                endbuild_order=15.0)
-```
-
-Each time you choose the order `15.0` because it is a good value for processing that
-made checks on the values. Indeed, processings that change the values such as
-`ProcessCopy` have an order that is generally $\leq$ 10.0.
-
-It is also possible to pass the flat config as a second argument to the function
-similarly to `create_processing_value`.
-
-## Create your processing classes (Advanced)
-
-To create your own processing classes and unlock more possibilities, you simply
-need to overload the methods of the Processing class to modify the config at the
-desired timings. To do so, you often need to manipulate tags.
-
-### Manipulate the tags
-
-Tags are useful for triggering a processing, as we have seen. However, we need
-to be cautious because tagging a key modifies its name and can lead to conflicts
-when using processing. To address this issue, we provide tag routines in
-`cliconfig.tag_routines`. These routines include:
-
-* `is_tag_in`: Checks if a tag is in a key. It looks for the exact tag name.
-  If `full_key` is True, it looks for all the flat key, including sub-configs
-  (default: False)
-* `clean_tag`: Removes a specific tag (based on its exact name) from a key.
-  It is helpful to remove the tag after pre-merging.
-* `clean_all_tags`: Removes all tags from a key. This is helpful each time you
-  need the true parameter name.
-* `clean_dict_tags`: Removes all tags from a dictionary and returns the cleaned
-  dict along with a list of keys that contained tags. This is helpful to get all
-  the parameter names of a full dict with tags.
-
-With these tools, we can write a processing, for example, that searches for all
-parameters with a tag `@look` ant that prints their sorted values at the end of
-the post-merging.
-
-```python
-class ProcessPrintSorted(Processing):
-    """Print the parameters tagged with "@look", sorted by value on post-merge."""
-
-    def __init__(self) -> None:
-        super().__init__()
-        self.looked_keys: Set[str] = set()
-        # Pre-merge just look for the tag so order is not important
-        self.premerge_order = 0.0
-        # Post-merge should be after the copy processing if we want the final values
-        # on post-merge
-        self.postmerge_order = 15.0
-
-    def premerge(self, flat_config: Config) -> Config:
-        """Pre-merge processing."""
-        # Browse a freeze version of the dict (because we will modify it to remove tags)
-        items = list(flat_config.dict.items())
-        for flat_key, value in items:
-            if is_tag_in(flat_key, "look"):  # Check if the key contains the tag
-                # Remove the tag and update the dict
-                new_key = clean_tag(flat_key, "look")
-                flat_config.dict[new_key] = value
-                del flat_config.dict[flat_key]
-                # Store the key
-                clean_key = clean_all_tags(key)  # remove all tags = true parameter name
-                self.looked_keys.add(clean_key)
-        return flat_config
-
-    def postmerge(self, flat_config: Config) -> Config:
-        """Post-merge processing."""
-        values = []
-        for key in self.looked_keys:
-            # IMPORTANT
-            # ("if key in flat_config.dict:" is important in case of some keys were
-            # removed or if multiple dicts with different parameters are seen by
-            # the processing)
-            if key in flat_config.dict:
-                values.append(flat_config.dict[key])
-        print("The sorted looked values are: ", sorted(values))
-        # If we don't want to keep the looked keys for further print:
-        self.looked_keys = set()
-
-        return flat_config
-
-# And to use it:
-config = make_config("main.yaml", process_list=[ProcessPrintSorted()])
-```
-
-**Important note**: After all pre-merge processings, the config should no longer contains
-tags as they should be removed by pre-merge processings. Otherwise, a security
-processing raises an error. It is then not necessary to take care on tags on post-merge,
-and pre-save.
-
-### Merge, save or load configs in processing
-
-The key concept is that as long as we deal with processings, the elementary operations
-on the config are not actually to merge, save, and load a config, but rather:
-
-* Applying pre-merge processing, then merging, then applying post-merge processing.
-* Applying pres-ave processing and then saving a config.
-* Loading a config and then applying post-load processing.
-
-These three operations are in `cliconfig.process_routines` and called
-`merge_processing`, `save_processing`, and `load_processing`, respectively. They
-take as input a Config object that contains as we see the list of processing.
-
-Now, the trick is that sometimes we want to apply these operations to the processing
-themselves, particularly when we want to modify a part of the configuration instead
-of just a single parameter (such as merging two configurations). This is why it is
-particularly useful to have access to the full Config object and not only the
-dict.
-
-For example, consider the tag `@merge_add`, which triggers a processing before
-merging and merges the config loaded from a specified path (the value) into the
-current config. We may want to see what happens if we merge a config that also
-contains a `@merge_add` tag within it:
-
-```yaml
-# main.yaml
-config_path1@merge_add: path1.yaml
-# path1.yaml
-param1: 1
-config_path2@merge_add: path2.yaml
-# path2.yaml
-param2: 2
-```
-
-Now, let's consider we want to merge the config `main.yaml` with another config.
-During the pre-merge processing, we encounter the tag `@merge_add`. This tag is
-removed, and the config found at `path1.yaml` will be merged into the `main.yaml`
-config. However before this, it triggers the pre-merging.
-
-Therefore, before the merge `path1.yaml`, the processing discovers the key
-`config_path2@merge_add` and merges the config found at `path2.yaml` into `path1.yaml`.
-Then, `path1.yaml` is merged into `main.yaml`. Finally, the resulting configuration
-can be interpreted as follows:
-
-```python
-{'param1': 1, 'param2': 2, 'config_path1': 'path1.yaml', 'config_path2': 'path2.yaml'}
-```
-
-before being merged itself with another config. Note that is not only a processing that
-allows to organize the configuration on multiple files. In fact, it also allows you for
-instance to choose a particular configuration among several ones by setting the path
-as value of the tagged key (as long as this config is on the default configs).
-
-### Change processing list in processing (Still more advanced)
-
-Note that the processing functions receive the list of processing objects as an
-input and update as an attribute of the processing object. This means that it
-is possible to manually modify this list in processing functions.
-
-**Warning**: The processing list to apply during pre/post-merge, pre-save and
-post-load are determined before the first processing is applied. Therefore, you can't
-add or remove processing and expect it to be effective during the current merge/save/load.
-However, if you modify their internal variables it will be effective immediately.
-
-Here an example of a processing that remove the type check of a parameter in
-`ProcessTyping` processing. It is then possible for instance to force another
-type (it is not possible otherwise).
-
-```python
-from cliconfig.processing.builtin import ProcessTyping
-
-class ProcessBypassTyping(Processing):
-    """Bypass type check of ProcessTyping for parameters tagged with "@bypass_typing".
-
-    In pre-merge it looks for a parameter with the tag "@bypass_typing",
-    removes it and change the internal ProcessTyping variables to avoid
-    checking the type of the parameter with ProcessTyping.
-    """
-
-    def __init__(self) -> None:
-        super().__init__()
-        self.bypassed_forced_types: Dict[str, tuple] = {}
-        # Before ProcessTyping pre-merge to let it change the type
-        self.premerge_order = 1.0
-
-    def premerge(self, flat_config: Config) -> Config:
-        """Pre-merge processing."""
-        items = list(flat_config.dict.items())
-        for flat_key, value in items:
-            if is_tag_in(flat_key, "bypass_typing"):
-                new_key = clean_tag(flat_key, "bypass_typing")
-                flat_config.dict[new_key] = value
-                del flat_config.dict[flat_key]
-                clean_key = clean_all_tags(flat_key)
-                for processing in flat_config.process_list:
-                    if (isinstance(processing, ProcessTyping)
-                            and clean_key in processing.forced_types):
-                        forced_type = processing.forced_types.pop(clean_key)
-                        self.bypassed_forced_types[clean_key] = forced_type
-        return flat_config
-
-# Without bypass:
-config1 = Config({"a@type:int": 0}, [ProcessBypassTyping(), ProcessTyping()])
-config2 = Config({"a@type:str": "a"}, [])
-config = merge_flat_processing(config1, config2)
-# > Error: try to change the forced type of "a" from int to str
-
-# With bypass:
-config1 = Config({"a@type:int": 0}, [ProcessBypassTyping(), ProcessTyping()])
-config2 = Config({"a@bypass_typing@type:str": "a"}, [])
-config = merge_flat_processing(config1, config2)
-# > No error
-```
diff --git a/docs/quickstart.md b/docs/quickstart.md
deleted file mode 100644
index 9647c73..0000000
--- a/docs/quickstart.md
+++ /dev/null
@@ -1,202 +0,0 @@
-# Quick start
-
-Create yaml file(s) that contains your default configurations. All the parameters
-should be listed (see later to organize them simply in case of big config files).
-
-```yaml
-# default1.yaml
-param1: 1
-param2: 1
-letters:
-  letter1: a
-  letter2: b
-
-# default2.yaml
-param1: 1
-param2: 2  # will override param2 from default1.yaml
-letters.letter3: c  # add a new parameter
-```
-
-Get your config in your python code:
-
-```python
-# main.py
-from cliconfig import make_config
-
-config = make_config('default1.yaml', 'default2.yaml')
-```
-
-Add additional config file(s) that represent your experiments. They will override
-the default values. Please note that new parameters that are not present in the
-default configs are not allowed. This restriction is in place to prevent potential
-typos in the config files from going unnoticed. It also enhances the readability
-of the default config files and ensures retro-compatibility (see later to circumnavigate
-it for particular cases). This restriction apart, the package allows a complete
-liberty of config manipulation.
-
-```yaml
-# first.yaml
-letters:
-  letter3: C  # equivalent to "letters.letter3: 'C'"
-
-# second.yaml
-param1: -1
-letters.letter1: A
-```
-
-Run your code with the additional config files AND eventually some other parameters
-from command line. **Please respect the exact syntax for spaces and equal signs**.
-
-```bash
-python main.py --config first.yaml,second.yaml --param2=-2 --letters.letter2='B'
-```
-
-If you have multiple config files it is possible to pass a list with bracket.
-Be careful, using ``--config=first.yaml`` will NOT be recognized as an additional config
-file (space is important) but as a parameter called "config" with value "first.yaml"
-(it then raises an error if no "config" parameter is on the default config).
-
-```bash
-Now the config look like this:
-
-Config:
-    param1: -1  # (overridden by second.yaml)
-    param2: -2  # (overridden by command line args)
-    letters:
-        letter1: A  # (overridden by second.yaml)
-        letter2: B  # (overridden by command line args)
-        letter3: C  # (overridden by first.yaml)
-```
-
-You can also manipulate your config with the following functions:
-
-```python
-from cliconfig import load_config, save_config, show_config, update_config
-show_config(config)  # print it
-config.dict  # config as native dict
-config.dict['letters']['letter1']  # access parameter via dict
-config.letters.letter1  # access parameter via dots
-config.letters.letter1 = 'G'  # modify parameter
-del config.letters.letter1  # delete parameter
-# Update config with a dict or another config
-config = update_config(config, {'letters': {'letter1': 'H'}})
-# Save the config as a yaml file
-save_config(config, 'myconfig.yaml')
-# Load the config and merge with the default configs if provided
-# (useful if default configs were updated)
-config = load_config('myconfig.yaml', default_config_paths=['default1.yaml', 'default2.yaml'])
-```
-
-The config object is just a wrapper around the config dict that allows to access the parameters
-via dots (and containing the list of processings, see
-[*Processing*](https://cliconfig.readthedocs.io/en/latest/processing.html)
-section for details). That's all! Therefore, the config object is
-very light and simple to use.
-
-While the config object is simple, the possibilities to manipulate the config are endless.
-See the next section for some default features. One of the core idea of this package is
-to make it easy to add your own features for your specific needs.
-
-## Use tags
-
-By default, the package provides some "tags" represented as strings that start with
-'@' and are placed at the end of a key containing a parameter. These tags change
-the way the configuration is processed.
-
-The default tags include:
-
-* `@merge_add`, `@merge_before`, and `@merge_after`: These tags merge the dictionary
-  loaded from the specified value (which should be a YAML path) into the current
-  configuration. `@merge_add` allows only the merging of new keys and is useful for
-  splitting non-overlapping sub-configurations into multiple files. `@merge_before` merges
-  the current dictionary onto the loaded one, while `@merge_after` merges the loaded
-  dictionary onto the current one. These tags are used to organize the config files simply.
-* `@copy`: This tag copies a parameter from another key. The value should be a string
-  that represents the flattened key. The copied value is then protected from further
-  updates but will be updated if the copied key change during a merge.
-* `@def`: This tag evaluate an expression to define the parameter value.
-  The value associated to a parameter tagged with `@def` can contain any
-  parameter name of the configuration. The most useful operators and built-in
-  functions are supported, the random and math packages are also supported
-  as well as some (safe) numpy, jax, tensorflow, pytorch functions.
-  If/else statements and comprehension lists are also supported.
-* `@type:<my type>`: This tag checks if the key matches the specified type `<my type>`
-  after each update, even if the tag is no longer present. It tries to convert
-  the type if it is not the good one. It supports basic types
-  (except for tuples and sets, which are not handled by YAML) as well as unions
-  (using "Union" or "|"), optional values, nested list, and nested dict.
-  For instance: `@type:List[Dict[str, int|float]]`.
-* `@select`: This tag select param/sub-config(s) to keep and delete the other
-  param/sub-configs in the same parent config. The tagged key is not deleted if
-  it is in the parent config.
-* `@delete`: This tag deletes the param/sub-config from the config before merging.
-  It is usefull to trigger a processing without keeping the key in the config.
-* `@new`: This tag allows adding new key(s) to the config that are not already
-  present in the default config(s). It can be used for single parameter or a
-  sub-config. Disclaimer: it is preferable to have exhaustive default config(s)
-  instead of abusing this tag for readability and for security concerning typos.
-* `@dict`: This tag allows to have a dictionary object instead of a sub-config
-  where you can modify the keys (see the
-  [*Edge cases*](https://cliconfig.readthedocs.io/en/latest/edge_cases.html) section)
-
-The tags are applied in a particular order that ensure no conflict between them.
-
-Please note that the tags serve as triggers for internal processing and will be
-automatically removed from the key after processing.
-
-It is also possible to combine multiple tags. For example:
-
-```yaml
-# main.yaml
-path_1@merge_add: sub1.yaml
-path_2@merge_add: sub2.yaml
-config3.selection@delete@select: config3.param1
-
-# sub1.yaml
-config1:
-  param@copy@type:int: config2.param
-  param2@type:float: 1  # wrong type -> converted to float
-
-# sub2.yaml
-config2.param: 2
-config3:
-  param1@def: "[(config1.param2 + config2.param) / 2] * 2 if config2.param else None"
-  param2: 3
-my_dict@dict:
-  key1: 1
-  key2: 2
-```
-
-Note that can also use YAML tags separated with "@" (like `key: !tag@tag2 value`)
-to add tags instead of putting them in the parameter name (like `key@tag@tag2: value`).
-
-Here `main.yaml` is interpreted like:
-
-```yaml
-path_1: sub1.yaml
-path_2: sub2.yaml
-config1:
-  param: 2  # the value of config2.param
-  param2: 1.0  # converted to float
-config2:
-  param: 2
-config3:
-  param1: [1.5, 1.5]
-  # param2 is deleted because it is not in the selection
-my_dict: {key1: 1, key2: 2}  # (changing the whole dict further is allowed)
-```
-
-Then, all the parameters in `config1` have enforced types, changing
-`config2.param` will also update `config1.param` accordingly (which is
-protected by direct update). Finally, changing `config1.param2` or `config2.param`
-will update `config3.param1` accordingly until a new value is set for `config3.param1`.
-
-These side effects are not visible in the config but stored on processing objects.
-They are objects that find the tags, remove them from config and apply a modification.
-These processing are powerful tools that can be used to highly customize the
-configuration at each step of the process.
-
-You can easily create your own processing (associated to a tag or not).
-The way to do it and a further explanation of them is available in the
-[*Processing*](https://cliconfig.readthedocs.io/en/latest/processing.html) section
-of the documentation.
diff --git a/docs/requirements.txt b/docs/requirements.txt
index d5e7262..893ed93 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,8 +1,1 @@
-autoapi==2.0.1
-myst-parser==1.0.0
-setuptools_scm==7.1.0
-sphinx-autoapi==2.1.0
-sphinx-autodoc-typehints==1.23.0
-sphinx_rtd_theme==1.2.0
-urllib3==1.26.6  # urllib >= 2.0 may be not supported by readthedocs (old OpenSSL version)
-.
+pdoc
diff --git a/docs/sweep.md b/docs/sweep.md
deleted file mode 100644
index a189efb..0000000
--- a/docs/sweep.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# Hyperparameter search with Weights&Biases
-
-Making hyperparameter search easier and more effective with
-[Weights&Biases sweeps](https://wandb.ai/site)! This example shows you how to combine them
-with cliconfig supporting nested configuration:
-
-```python
-# main.py
-from cliconfig.config_routines import update_config
-from cliconfig.dict_routines import flatten
-import wandb
-
-def main() -> None:
-    """Main function."""
-    # Create a cliconfig based on CLI
-    config = make_config('default.yaml')
-    # Initialize wandb to create wandb.config eventually modified by sweep
-    # Note that the config is flattened because wandb sweep does not support
-    # nested config (yet)
-    wandb.init(config=flatten(config.dict))
-    # Sync the cliconfig with wandb.config
-    config = update_config(config, wandb.config)
-    # Now the config is eventually updated with the sweep,
-    # unflattened and ready to be used
-
-    run(config)
-
-if __name__ == '__main__':
-    main()
-```
-
-Now you can create your sweep configuration use wandb sweep either from CLI or
-from python following the [wandb tutorial](https://docs.wandb.ai/guides/sweeps).
-
-For instance with a configuration containing train and data sub-configurations:
-
-```yaml
-# sweep.yaml
-program: main.py
-method: bayes
-metric:
-  name: val_loss
-  goal: minimize
-parameters:
-    train.learning_rate:
-        distribution: log_uniform_values
-        min: 0.0001
-        max: 0.1
-    train.optimizer.name:
-        values: ["adam", "sgd"]
-    data.batch_size:
-        values: [32, 64, 128]
-```
-
-```bash
-$ wandb sweep sweep.yaml
-sweep_id: ...
-$ wandb agent <sweep_id>
-```
-
-This makes a bayesian search over the learning rate, the optimizer and the batch size to minimize the final validation loss.
diff --git a/docs/template/README.md b/docs/template/README.md
new file mode 100644
index 0000000..56479f3
--- /dev/null
+++ b/docs/template/README.md
@@ -0,0 +1,43 @@
+# 📑 pdoc templates
+
+This directory contains pdoc's default templates, which you can extend in your own template directory. See
+[the documentation](https://pdoc.dev/docs/pdoc.html#edit-pdocs-html-template) for an example.
+
+For customization, the most important files are:
+
+**Main HTML Templates**
+
+ - `default/module.html.jinja2`: Template for documentation pages.
+ - `default/index.html.jinja2`: Template for the top-level `index.html`.
+ - `default/frame.html.jinja2`: The common page layout for `module.html.jinja2` and `index.html.jinja2`.
+
+**CSS Stylesheets**
+
+ - `custom.css`: Empty be default, add custom additional rules here.
+ - `content.css`: All style definitions for documentation contents.
+ - `layout.css`: All style definitions for the page layout (navigation, sidebar, ...).
+ - `theme.css`: Color definitions (see [`examples/dark-mode`](../../examples/dark-mode)).
+ - `syntax-highlighting.css`: Code snippet style, see below.
+
+## Extending templates
+
+pdoc will first check for `$template.jinja2` before checking `default/$template.jinja2`. This allows you to reuse the
+macros from the main templates in `default/`. For example, you can create a `module.html.jinja2` file in your custom
+template directory that extends the default template as follows:
+
+```html
+{% extends "default/module.html.jinja2" %}
+{% block title %}new page title{% endblock %}
+```
+
+## Syntax Highlighting
+
+The `syntax-highlighting.css` file contains the CSS styles used for syntax highlighting.
+It is generated as follows:
+
+```
+pygmentize -f html -a .pdoc-code -S <theme> > default/syntax-highlighting.css
+```
+
+The default theme is `default`, with extended padding added to the `.linenos` class.
+Alternative color schemes can be tested on [the Pygments website](https://pygments.org/demo/).
diff --git a/docs/template/build-search-index.js b/docs/template/build-search-index.js
new file mode 100644
index 0000000..7661d27
--- /dev/null
+++ b/docs/template/build-search-index.js
@@ -0,0 +1,30 @@
+/**
+ * This script is invoked by pdoc to precompile the search index.
+ * Precompiling the search index increases file size, but skips the CPU-heavy index building in the browser.
+ */
+let elasticlunr = require("./resources/elasticlunr.min");
+
+let fs = require("fs");
+let docs = JSON.parse(fs.readFileSync(0, "utf-8"));
+
+/* mirrored in search.js.jinja2  (part 1) */
+elasticlunr.tokenizer.setSeperator(/[\s\-.;&_'"=,()]+|<[^>]*>/);
+
+/* mirrored in search.js.jinja2  (part 2) */
+searchIndex = elasticlunr(function () {
+    this.pipeline.remove(elasticlunr.stemmer);
+    this.pipeline.remove(elasticlunr.stopWordFilter);
+    this.addField("qualname");
+    this.addField("fullname");
+    this.addField("annotation");
+    this.addField("default_value");
+    this.addField("signature");
+    this.addField("bases");
+    this.addField("doc");
+    this.setRef("fullname");
+});
+for (let doc of docs) {
+    searchIndex.addDoc(doc);
+}
+
+process.stdout.write(JSON.stringify(searchIndex.toJSON()));
diff --git a/docs/template/content.css b/docs/template/content.css
new file mode 100644
index 0000000..2c0d94f
--- /dev/null
+++ b/docs/template/content.css
@@ -0,0 +1,427 @@
+/*
+This CSS file contains all style definitions for documentation content.
+
+All selectors are scoped with ".pdoc".
+This makes sure that the pdoc styling doesn't leak to the rest of the page when pdoc is embedded.
+*/
+
+.pdoc {
+    color: var(--text);
+    /* enforce some styling even if bootstrap reboot is not included */
+    box-sizing: border-box;
+    line-height: 1.5;
+    /* override background from pygments */
+    /*unnecessary since pdoc 10, only left here to keep old custom templates working. */
+    background: none;
+}
+
+.pdoc .pdoc-button {
+    display: inline-block;
+    border: solid black 1px;
+    border-radius: 2px;
+    font-size: .75rem;
+    padding: calc(0.5em - 1px) 1em;
+    transition: 100ms all;
+}
+
+
+/* Admonitions */
+.pdoc .pdoc-alert {
+    padding: 1rem 1rem 1rem calc(1.5rem + 24px);
+    border: 1px solid transparent;
+    border-radius: .25rem;
+    background-repeat: no-repeat;
+    background-position: 1rem center;
+    margin-bottom: 1rem;
+}
+
+.pdoc .pdoc-alert>*:last-child {
+    margin-bottom: 0;
+}
+
+/* Admonitions are currently not stylable via theme.css */
+.pdoc .pdoc-alert-note {
+    color: #084298;
+    background-color: #cfe2ff;
+    border-color: #b6d4fe;
+    background-image: url("data:image/svg+xml,{% filter urlencode %}{% include 'resources/info-circle-fill.svg' %}{% endfilter %}");
+}
+
+.pdoc .pdoc-alert-warning {
+    color: #664d03;
+    background-color: #fff3cd;
+    border-color: #ffecb5;
+    background-image: url("data:image/svg+xml,{% filter urlencode %}{% include 'resources/exclamation-triangle-fill.svg' %}{% endfilter %}");
+}
+
+.pdoc .pdoc-alert-danger {
+    color: #842029;
+    background-color: #f8d7da;
+    border-color: #f5c2c7;
+    background-image: url("data:image/svg+xml,{% filter urlencode %}{% include 'resources/lightning-fill.svg' %}{% endfilter %}");
+}
+
+.pdoc .visually-hidden {
+    position: absolute !important;
+    width: 1px !important;
+    height: 1px !important;
+    padding: 0 !important;
+    margin: -1px !important;
+    overflow: hidden !important;
+    clip: rect(0, 0, 0, 0) !important;
+    white-space: nowrap !important;
+    border: 0 !important;
+}
+
+.pdoc h1,
+.pdoc h2,
+.pdoc h3 {
+    font-weight: 300;
+    margin: .3em 0;
+    padding: .2em 0;
+}
+
+.pdoc>section:not(.module-info) h1 {
+    font-size: 1.5rem;
+    font-weight: 500;
+}
+
+.pdoc>section:not(.module-info) h2 {
+    font-size: 1.4rem;
+    font-weight: 500;
+}
+
+.pdoc>section:not(.module-info) h3 {
+    font-size: 1.3rem;
+    font-weight: 500;
+}
+
+.pdoc>section:not(.module-info) h4 {
+    font-size: 1.2rem;
+}
+
+.pdoc>section:not(.module-info) h5 {
+    font-size: 1.1rem;
+}
+
+.pdoc a {
+    text-decoration: none;
+    color: var(--link);
+}
+
+.pdoc a:hover {
+    color: var(--link-hover);
+}
+
+.pdoc blockquote {
+    margin-left: 2rem;
+}
+
+.pdoc pre {
+    border-top: 1px solid var(--accent2);
+    border-bottom: 1px solid var(--accent2);
+    margin-top: 0;
+    margin-bottom: 1em;
+    padding: .5rem 0 .5rem .5rem;
+    overflow-x: auto;
+}
+
+.pdoc code {
+    color: var(--code-text);
+    padding: .2em .4em;
+    margin: 0;
+    font-size: 85%;
+    background-color: var(--code);
+    border-radius: 6px;
+}
+
+.pdoc a>code {
+    color: inherit;
+}
+
+.pdoc pre>code {
+    display: inline-block;
+    font-size: inherit;
+    background: none;
+    border: none;
+    padding: 0;
+}
+
+.pdoc>section:not(.module-info) {
+    /* this margin should collapse with docstring margin,
+       but not for the module docstr which is followed by view_source. */
+    margin-bottom: 1.5rem;
+}
+
+/* Page Heading */
+.pdoc .modulename {
+    margin-top: 0;
+    font-weight: bold;
+}
+
+.pdoc .modulename a {
+    color: var(--link);
+    transition: 100ms all;
+}
+
+/* GitHub Button */
+.pdoc .git-button {
+    float: right;
+    border: solid var(--link) 1px;
+}
+
+.pdoc .git-button:hover {
+    background-color: var(--link);
+    color: var(--pdoc-background);
+}
+
+.view-source-toggle-state,
+.view-source-toggle-state~.pdoc-code {
+    display: none;
+}
+
+.view-source-toggle-state:checked~.pdoc-code {
+    display: block;
+}
+
+.view-source-button {
+    display: inline-block;
+    float: right;
+    font-size: .75rem;
+    line-height: 1.5rem;
+    color: var(--muted);
+    padding: 0 .4rem 0 1.3rem;
+    cursor: pointer;
+    /* odd hack to reduce space between "bullet" and text */
+    text-indent: -2px;
+}
+
+.view-source-button>span {
+    visibility: hidden;
+}
+
+.module-info .view-source-button {
+    float: none;
+    display: flex;
+    justify-content: flex-end;
+    margin: -1.2rem .4rem -.2rem 0;
+}
+
+.view-source-button::before {
+    /* somewhat awkward recreation of a <summary> element. ideally we'd just use `display: inline list-item`, but
+     that does not work in Chrome (yet), see https://crbug.com/995106. */
+    position: absolute;
+    content: "View Source";
+    display: list-item;
+    list-style-type: disclosure-closed;
+}
+
+.view-source-toggle-state:checked~.attr .view-source-button::before,
+.view-source-toggle-state:checked~.view-source-button::before {
+    list-style-type: disclosure-open;
+}
+
+/* Docstrings */
+.pdoc .docstring {
+    margin-bottom: 1.5rem;
+}
+
+.pdoc section:not(.module-info) .docstring {
+    margin-left: clamp(0rem, 5vw - 2rem, 1rem);
+}
+
+.pdoc .docstring .pdoc-code {
+    margin-left: 1em;
+    margin-right: 1em;
+}
+
+/* Highlight focused element */
+.pdoc h1:target,
+.pdoc h2:target,
+.pdoc h3:target,
+.pdoc h4:target,
+.pdoc h5:target,
+.pdoc h6:target,
+.pdoc .pdoc-code>pre>span:target {
+    background-color: var(--active);
+    box-shadow: -1rem 0 0 0 var(--active);
+}
+
+.pdoc .pdoc-code>pre>span:target {
+    /* make the highlighted line full width so that the background extends */
+    display: block;
+}
+
+.pdoc div:target>.attr,
+.pdoc section:target>.attr,
+.pdoc dd:target>a {
+    background-color: var(--active);
+}
+
+.pdoc * {
+    scroll-margin: 2rem;
+}
+
+.pdoc .pdoc-code .linenos {
+    user-select: none;
+}
+
+.pdoc .attr:hover {
+    filter: contrast(0.95);
+}
+
+/* Header link */
+.pdoc section,
+.pdoc .classattr {
+    position: relative;
+}
+
+.pdoc .headerlink {
+    --width: clamp(1rem, 3vw, 2rem);
+    position: absolute;
+    top: 0;
+    left: calc(0rem - var(--width));
+    transition: all 100ms ease-in-out;
+    opacity: 0;
+}
+
+.pdoc .headerlink::before {
+    content: "#";
+    display: block;
+    text-align: center;
+    width: var(--width);
+    height: 2.3rem;
+    line-height: 2.3rem;
+    font-size: 1.5rem;
+}
+
+.pdoc .attr:hover~.headerlink,
+.pdoc *:target>.headerlink,
+.pdoc .headerlink:hover {
+    opacity: 1;
+}
+
+/* Attributes */
+.pdoc .attr {
+    display: block;
+    margin: .5rem 0 .5rem;
+    padding: .4rem .4rem .4rem 1rem;
+    background-color: var(--code);
+    overflow-x: auto;
+    color: var(--code-text);
+}
+
+.pdoc .classattr {
+    margin-left: 2rem;
+}
+
+.pdoc .name {
+    color: var(--name);
+    font-weight: bold;
+}
+
+.pdoc .def {
+    color: var(--def);
+    font-weight: bold;
+}
+
+.pdoc .signature {
+    /* override pygments background color */
+    background-color: transparent;
+}
+
+.pdoc .param,
+.pdoc .return-annotation {
+    white-space: pre;
+}
+
+.pdoc .signature.multiline .param {
+    display: block;
+}
+
+.pdoc .signature.condensed .param {
+    display: inline-block;
+}
+
+.pdoc .annotation {
+    color: var(--annotation);
+}
+
+/* Inherited Members */
+.pdoc .inherited {
+    margin-left: 2rem;
+}
+
+.pdoc .inherited dt {
+    font-weight: 700;
+}
+
+.pdoc .inherited dt,
+.pdoc .inherited dd {
+    display: inline;
+    margin-left: 0;
+    margin-bottom: .5rem;
+}
+
+.pdoc .inherited dd:not(:last-child):after {
+    content: ", ";
+}
+
+.pdoc .inherited .class:before {
+    content: "class ";
+}
+
+.pdoc .inherited .function a:after {
+    content: "()";
+}
+
+/* Search results */
+.pdoc .search-result .docstring {
+    overflow: auto;
+    max-height: 25vh;
+}
+
+.pdoc .search-result.focused>.attr {
+    background-color: var(--active);
+}
+
+/* "built with pdoc" attribution */
+.pdoc .attribution {
+    margin-top: 2rem;
+    display: block;
+    opacity: 0.5;
+    transition: all 200ms;
+    filter: grayscale(100%);
+}
+
+.pdoc .attribution:hover {
+    opacity: 1;
+    filter: grayscale(0%);
+}
+
+.pdoc .attribution img {
+    margin-left: 5px;
+    height: 35px;
+    vertical-align: middle;
+    width: 70px;
+    transition: all 200ms;
+}
+
+/* Tables */
+.pdoc table {
+    display: block;
+    width: max-content;
+    max-width: 100%;
+    overflow: auto;
+    margin-bottom: 1rem;
+}
+
+.pdoc table th {
+    font-weight: 600;
+}
+
+.pdoc table th,
+.pdoc table td {
+    padding: 6px 13px;
+    border: 1px solid var(--accent2);
+}
\ No newline at end of file
diff --git a/docs/template/custom.css b/docs/template/custom.css
new file mode 100644
index 0000000..bea290d
--- /dev/null
+++ b/docs/template/custom.css
@@ -0,0 +1,23 @@
+.pdoc h1,
+.pdoc h2,
+.pdoc h3,
+.pdoc h4 {
+    font-weight: 500;
+
+}
+
+h1 {
+    color: #57178b;
+}
+
+h2 {
+    color: #af1488;
+}
+
+h3 {
+    color: #ff004c;
+}
+
+h4 {
+    color: #000000;
+}
\ No newline at end of file
diff --git a/docs/template/layout.css b/docs/template/layout.css
new file mode 100644
index 0000000..8a94fe6
--- /dev/null
+++ b/docs/template/layout.css
@@ -0,0 +1,237 @@
+/*
+This CSS file contains all style definitions for the global page layout.
+
+When pdoc is embedded into other systems, it may be left out (or overwritten with an empty file) entirely.
+ */
+
+/* Responsive Layout */
+html,
+body {
+    width: 100%;
+    height: 100%;
+}
+
+html,
+main {
+    scroll-behavior: smooth;
+}
+
+body {
+    background-color: var(--pdoc-background);
+}
+
+@media (max-width: 769px) {
+    #navtoggle {
+        cursor: pointer;
+        position: absolute;
+        width: 50px;
+        height: 40px;
+        top: 1rem;
+        right: 1rem;
+        border-color: var(--text);
+        color: var(--text);
+        display: flex;
+        opacity: 0.8;
+    }
+
+    #navtoggle:hover {
+        opacity: 1;
+    }
+
+    #togglestate+div {
+        display: none;
+    }
+
+    #togglestate:checked+div {
+        display: inherit;
+    }
+
+    main,
+    header {
+        padding: 2rem 3vw;
+    }
+
+    header+main {
+        margin-top: -3rem;
+    }
+
+    .git-button {
+        display: none !important;
+    }
+
+    nav input[type="search"] {
+        /* don't overflow into menu button */
+        max-width: 77%;
+    }
+
+    nav input[type="search"]:first-child {
+        /* align vertically with the hamburger menu */
+        margin-top: -6px;
+    }
+
+    nav input[type="search"]:valid~* {
+        /* hide rest of the menu when search has contents */
+        display: none !important;
+    }
+}
+
+@media (min-width: 770px) {
+    :root {
+        --sidebar-width: clamp(12.5rem, 28vw, 22rem);
+    }
+
+    nav {
+        position: fixed;
+        overflow: auto;
+        height: 100vh;
+        width: var(--sidebar-width);
+    }
+
+    main,
+    header {
+        padding: 3rem 2rem 3rem calc(var(--sidebar-width) + 3rem);
+        width: calc(54rem + var(--sidebar-width));
+        max-width: 100%;
+    }
+
+    header+main {
+        margin-top: -4rem;
+    }
+
+    #navtoggle {
+        display: none;
+    }
+}
+
+#togglestate {
+    /*
+    Don't do `display: none` here.
+    When a mobile browser is not scrolled all the way to the top,
+    clicking the label would insert the menu above the scrolling position
+    and it would stay out of view. By making the checkbox technically
+    visible it jumps up first and we always get the menu into view when clicked.
+    */
+    position: absolute;
+    height: 0;
+    /* height:0 isn't enough in Firefox, so we hide it extra well */
+    opacity: 0;
+}
+
+/* Nav */
+nav.pdoc {
+    --pad: clamp(0.5rem, 2vw, 1.75rem);
+    --indent: 1.5rem;
+    background-color: var(--nav-background);
+    border-right: 1px solid var(--nav-border);
+    box-shadow: 0 0 20px rgba(50, 50, 50, .2) inset;
+    padding: 0 0 0 var(--pad);
+    overflow-wrap: anywhere;
+    scrollbar-width: thin;
+    /* Scrollbar width on Firefox */
+    scrollbar-color: var(--nav-border) transparent
+        /* Scrollbar color on Firefox */
+}
+
+nav.pdoc::-webkit-scrollbar {
+    width: .4rem;
+    /* Scrollbar width on Chromium-based browsers */
+}
+
+nav.pdoc::-webkit-scrollbar-thumb {
+    background-color: var(--accent2);
+    /* Scrollbar color on Chromium-based browsers */
+}
+
+nav.pdoc>div {
+    padding: var(--pad) 0;
+}
+
+nav.pdoc .module-list-button {
+    display: inline-flex;
+    align-items: center;
+    color: var(--nav-text);
+    border-color: var(--muted);
+    margin-bottom: 1rem;
+}
+
+nav.pdoc .module-list-button:hover {
+    border-color: var(--text);
+}
+
+nav.pdoc input[type=search] {
+    display: block;
+    outline-offset: 0;
+    width: calc(100% - var(--pad));
+}
+
+nav.pdoc .logo {
+    max-width: calc(100% - var(--pad));
+    max-height: 35vh;
+    display: block;
+    margin: 0 auto 1rem;
+    transform: translate(calc(-.5 * var(--pad)), 0);
+}
+
+nav.pdoc ul {
+    list-style: none;
+    padding-left: 0;
+}
+
+nav.pdoc>div>ul {
+    /* undo padding here so that links span entire width */
+    margin-left: calc(0px - var(--pad));
+}
+
+nav.pdoc li a {
+    /* re-add padding (+indent) here */
+    padding: .2rem 0 .2rem calc(var(--pad) + var(--indent));
+}
+
+nav.pdoc>div>ul>li>a {
+    /* no padding for top-level */
+    padding-left: var(--pad);
+}
+
+nav.pdoc li {
+    transition: all 100ms;
+}
+
+nav.pdoc a,
+nav.pdoc {
+    color: var(--nav-text);
+}
+
+nav.pdoc a:hover {
+    background-color: var(--nav-hover);
+}
+
+nav.pdoc a {
+    display: block;
+}
+
+nav.pdoc>h2:first-of-type {
+    margin-top: 1.5rem;
+}
+
+nav.pdoc .class:before {
+    content: "class ";
+    color: var(--muted);
+}
+
+nav.pdoc .function:after {
+    content: "()";
+    color: var(--muted);
+}
+
+nav.pdoc footer:before {
+    content: "";
+    display: block;
+    width: calc(100% - var(--pad));
+    border-top: solid var(--accent2) 1px;
+    margin-top: 1.5rem;
+    padding-top: .5rem;
+}
+
+nav.pdoc footer {
+    font-size: small;
+}
\ No newline at end of file
diff --git a/docs/template/livereload.html.jinja2 b/docs/template/livereload.html.jinja2
new file mode 100644
index 0000000..ab5c1db
--- /dev/null
+++ b/docs/template/livereload.html.jinja2
@@ -0,0 +1,15 @@
+{# This templates implements live-reloading for pdoc's integrated webserver. #}
+<script>
+    /* Periodically check with the pdoc server if there have been any changes. */
+    let mtime_generated = {{ mtime | tojson }},
+        url = new URL(window.location);
+    url.searchParams.set("mtime", 1);
+    window.setInterval(function () {
+        fetch(url.toString())
+            .then(response => response.text())
+            .then(mtime => {
+                if (mtime_generated !== mtime)
+                    location.reload();
+            });
+    }, 1000);
+</script>
diff --git a/docs/template/math.html.jinja2 b/docs/template/math.html.jinja2
new file mode 100644
index 0000000..e181630
--- /dev/null
+++ b/docs/template/math.html.jinja2
@@ -0,0 +1,28 @@
+{# This template is included in math mode and loads MathJax for formula rendering. #}
+<script>
+    {#
+    MathJax by default does not define $ as a math delimiter because it's commonly used in non-mathematical settings.
+    We add it here. If you are unhappy with this default, you can override math.html.jinja2 with a custom template.
+     #}
+    window.MathJax = {
+        tex: {
+            inlineMath: [['$', '$'], ['\\(', '\\)']]
+        }
+    };
+</script>
+<script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
+<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
+<script>
+    /* Re-invoke MathJax when DOM content changes, for example during search. */
+    document.addEventListener("DOMContentLoaded", () => {
+        new MutationObserver(() => MathJax.typeset()).observe(
+            document.querySelector("main.pdoc").parentNode,
+            {childList: true}
+        );
+    })
+</script>
+<style>
+    mjx-container {
+        overflow-x: auto;
+    }
+</style>
diff --git a/docs/template/module.html.jinja2 b/docs/template/module.html.jinja2
new file mode 100644
index 0000000..3e3978c
--- /dev/null
+++ b/docs/template/module.html.jinja2
@@ -0,0 +1,304 @@
+{% extends "frame.html.jinja2" %}
+{% block title %}{{ module.modulename }} API documentation{% endblock %}
+{% block nav %}
+    {% block module_list_link %}
+        {% set parentmodule = ".".join(module.modulename.split(".")[:-1]) %}
+        {% if parentmodule and parentmodule in all_modules %}
+            <a class="pdoc-button module-list-button" href="../{{ parentmodule.split(".")[-1] }}.html">
+                {% include "resources/box-arrow-in-left.svg" %}
+                &nbsp;
+                {{- parentmodule -}}
+            </a>
+        {% elif not root_module_name %}
+            <a class="pdoc-button module-list-button" href="{{ "../" * module.modulename.count(".") }}index.html">
+                {% include "resources/box-arrow-in-left.svg" %}
+                &nbsp;
+                Module Index
+            </a>
+        {% endif %}
+    {% endblock %}
+
+    {% block nav_title %}
+        {% if logo %}
+            {% if logo_link %}<a href="{{ logo_link }}">{% endif %}
+            <img src="{{ logo }}" class="logo" alt="project logo"/>
+            {% if logo_link %}</a>{% endif %}
+        {% endif %}
+    {% endblock %}
+
+    {% block search_box %}
+        {% if search and all_modules|length > 1 %}
+            {# we set a pattern here so that we can use the :valid CSS selector #}
+            <input type="search" placeholder="Search..." role="searchbox" aria-label="search"
+                   pattern=".+" required>
+        {% endif %}
+    {% endblock %}
+
+    {% block nav_index %}
+        {% set index = module.docstring | to_markdown | to_html | attr("toc_html") %}
+        {% if index %}
+            <h2>Contents</h2>
+            {{ index | safe }}
+        {% endif %}
+    {% endblock %}
+
+    {% block nav_submodules %}
+        {% if module.submodules %}
+            <h2>Submodules</h2>
+            <ul>
+                {% for submodule in module.submodules if is_public(submodule) | trim %}
+                    <li>{{ submodule.taken_from | link(text=submodule.name) }}</li>
+                {% endfor %}
+            </ul>
+        {% endif %}
+    {% endblock %}
+
+    {% block nav_members %}
+        {% if module.members %}
+            <h2>API Documentation</h2>
+            {{ nav_members(module.members.values()) }}
+        {% endif %}
+    {% endblock %}
+
+    {% block nav_footer %}
+        {% if footer_text %}
+            <footer>{{ footer_text }}</footer>
+        {% endif %}
+    {% endblock %}
+
+    {% block attribution %}
+        <a class="attribution" title="pdoc: Python API documentation generator" href="https://pdoc.dev" target="_blank">
+            built with <span class="visually-hidden">pdoc</span><img
+                alt="pdoc logo"
+                src="data:image/svg+xml,
+                        {%- filter urlencode %}{% include "resources/pdoc-logo.svg" %}{% endfilter %}"/>
+        </a>
+    {% endblock %}
+{% endblock nav %}
+{% block content %}
+    <main class="pdoc">
+        {% block module_info %}
+            <section class="module-info">
+                {% block edit_button %}
+                    {% if edit_url %}
+                        {% if "github.com" in edit_url %}
+                            {% set edit_text = "Edit on GitHub" %}
+                        {% elif "gitlab" in edit_url %}
+                            {% set edit_text = "Edit on GitLab" %}
+                        {% else %}
+                            {% set edit_text = "Edit Source" %}
+                        {% endif %}
+                        <a class="pdoc-button git-button" href="{{ edit_url }}">{{ edit_text }}</a>
+                    {% endif %}
+                {% endblock %}
+                {{ docstring(module) }}
+                {{ view_source_state(module) }}
+                {{ view_source_button(module) }}
+                {{ view_source_code(module) }}
+            </section>
+        {% endblock %}
+        {% block module_contents %}
+            <h1> API Documentation </h1>
+            {% for m in module.flattened_own_members if is_public(m) | trim %}
+                <section id="{{ m.qualname or m.name }}">
+                    {{ member(m) }}
+                    {% if m.kind == "class" %}
+                        {% for m in m.own_members if m.kind != "class" and is_public(m) | trim %}
+                            <div id="{{ m.qualname }}" class="classattr">
+                                {{ member(m) }}
+                            </div>
+                        {% endfor %}
+                        {% set inherited_members = inherited(m) | trim %}
+                        {% if inherited_members %}
+                            <div class="inherited">
+                                <h5>Inherited Members</h5>
+                                <dl>
+                                    {{ inherited_members }}
+                                </dl>
+                            </div>
+                        {% endif %}
+                    {% endif %}
+                </section>
+            {% endfor %}
+        {% endblock %}
+    </main>
+    {% if mtime %}
+        {% include "livereload.html.jinja2" %}
+    {% endif %}
+    {% block search_js %}
+        {% if search and all_modules|length > 1 %}
+            {% include "search.html.jinja2" %}
+        {% endif %}
+    {% endblock %}
+{% endblock content %}
+{#
+End of content, beginning of helper macros.
+See https://pdoc.dev/docs/pdoc/render_helpers.html#DefaultMacroExtension for an explanation of defaultmacro.
+#}
+{% defaultmacro bases(cls) %}
+    {%- if cls.bases -%}
+        <wbr>(
+        {%- for base in cls.bases -%}
+            <span class="base">{{ base[:2] | link(text=base[2]) }}</span>
+            {%- if loop.nextitem %}, {% endif %}
+        {%- endfor -%}
+        )
+    {%- endif -%}
+{% enddefaultmacro %}
+{% defaultmacro default_value(var) -%}
+    {%- if var.default_value_str %}
+        =
+        {% if var.default_value_str | length > 100 -%}
+            <input id="{{ var.qualname }}-view-value" class="view-value-toggle-state" type="checkbox" aria-hidden="true" tabindex="-1">
+            <label class="view-value-button pdoc-button" for="{{ var.qualname }}-view-value"></label>
+        {%- endif -%}
+        <span class="default_value">{{ var.default_value_str | escape | linkify }}</span>
+    {%- endif -%}
+{% enddefaultmacro %}
+{% defaultmacro annotation(var) %}
+    {%- if var.annotation_str -%}
+        <span class="annotation">{{ var.annotation_str | escape | linkify }}</span>
+    {%- endif -%}
+{% enddefaultmacro %}
+{% defaultmacro decorators(doc) %}
+    {% for d in doc.decorators if not d.startswith("@_") %}
+        <div class="decorator">{{ d }}</div>
+    {% endfor %}
+{% enddefaultmacro %}
+{% defaultmacro function(fn) -%}
+    {{ decorators(fn) }}
+    {% if fn.name == "__init__" %}
+        <span class="name">{{ ".".join(fn.qualname.split(".")[:-1]) }}</span>
+        {{- fn.signature_without_self | format_signature(colon=False) | linkify }}
+    {% else %}
+        <span class="def">{{ fn.funcdef }}</span>
+        <span class="name">{{ fn.name }}</span>
+        {{- fn.signature | format_signature(colon=True) | linkify }}
+    {% endif %}
+{% enddefaultmacro %}
+{% defaultmacro variable(var) -%}
+    <span class="name">{{ var.name }}</span>{{ annotation(var) }}{{ default_value(var) }}
+{% enddefaultmacro %}
+{% defaultmacro submodule(mod) -%}
+    <span class="name">{{ mod.taken_from | link }}</span>
+{% enddefaultmacro %}
+{% defaultmacro class(cls) -%}
+    {{ decorators(cls) }}
+    <span class="def">class</span>
+    <span class="name">{{ cls.qualname }}</span>
+    {{- bases(cls) -}}:
+{% enddefaultmacro %}
+{% defaultmacro member(doc) %}
+    {{- view_source_state(doc) -}}
+    <div class="attr {{ doc.kind }}">
+        {% if doc.kind == "class" %}
+            {{ class(doc) }}
+        {% elif doc.kind == "function" %}
+            {{ function(doc) }}
+        {% elif doc.kind == "module" %}
+            {{ submodule(doc) }}
+        {% else %}
+            {{ variable(doc) }}
+        {% endif %}
+        {{ view_source_button(doc) }}
+    </div>
+    <a class="headerlink" href="#{{ doc.qualname or doc.name }}"></a>
+    {{ view_source_code(doc) }}
+    {{ docstring(doc) }}
+{% enddefaultmacro %}
+{% defaultmacro docstring(var) %}
+    {% if var.docstring %}
+        <div class="docstring">{{ var.docstring | to_markdown | to_html | linkify(namespace=var.qualname) }}</div>
+    {% endif %}
+{% enddefaultmacro %}
+{% defaultmacro nav_members(members) %}
+    <ul class="memberlist">
+        {% for m in members if is_public(m) | trim %}
+            <li>
+                {% if m.kind == "class" %}
+                    <a class="class" href="#{{ m.qualname }}">{{ m.qualname }}</a>
+                {% elif m.kind == "module" %}
+                    <a class="module" href="#{{ m.name }}">{{ m.name }}</a>
+                {% elif m.name == "__init__" %}
+                    <a class="function" href="#{{ m.qualname }}">{{ m.qualname.split(".")[-2] }}</a>
+                {% else %}
+                    <a class="{{ m.kind }}" href="#{{ m.qualname }}">{{ m.name }}</a>
+                {% endif %}
+            </li>
+        {% endfor %}
+    </ul>
+{% enddefaultmacro %}
+{% defaultmacro is_public(doc) %}
+    {#
+    This macro is a bit unconventional in that its output is not rendered, but treated as a boolean:
+    Returning no text is interpreted as false, returning any other text is iterpreted as true.
+    Implementing this as a macro makes it very easy to override with a custom template, see
+    https://github.com/mitmproxy/pdoc/tree/main/examples/custom-template.
+    #}
+    {% if not include_undocumented and not doc.docstring %}
+        {# hide members that are undocumented if include_undocumented has been toggled off. #}
+    {% elif doc.docstring and "@private" in doc.docstring %}
+        {# hide members explicitly marked as @private #}
+    {% elif doc.name == "__init__" and (doc.docstring or (doc.kind == "function" and doc.signature_without_self.parameters)) %}
+        {# show constructors that have a docstring or at least one extra argument #}
+        true
+    {% elif doc.name == "__doc__" %}
+        {# We don't want to document __doc__ itself, https://github.com/mitmproxy/pdoc/issues/235 #}
+    {% elif doc.kind == "variable" and doc.is_typevar and not doc.docstring %}
+        {# do not document TypeVars, that only clutters the docs. #}
+    {% elif not doc.name.startswith("_") %}
+        {# members not starting with an underscore are considered public by default #}
+        true
+    {% endif %}
+{% enddefaultmacro %}
+{# fmt: off #}
+{% defaultmacro inherited(cls) %}
+    {% for base, members in cls.inherited_members.items() %}
+        {% set m = None %}{# workaround for https://github.com/pallets/jinja/issues/1427 #}
+        {% set member_html %}
+            {% for m in members if is_public(m) | trim %}
+                <dd id="{{ m.qualname }}" class="{{ m.kind }}">
+                    {{- m.taken_from | link(text=m.name.replace("__init__",base[1])) -}}
+                </dd>
+            {% endfor %}
+        {% endset %}
+        {# we may not have any public members, in which case we don't want to print anything. #}
+        {% if member_html %}
+            <div><dt>{{ base | link }}</dt>
+                {{ member_html }}
+            </div>
+        {% endif %}
+    {% endfor %}
+{% enddefaultmacro %}
+{# fmt: on #}
+{% defaultmacro view_source_state(doc) %}
+    {% if show_source and doc.source %}
+        <input id="{{ doc.qualname or "mod-" + doc.name }}-view-source" class="view-source-toggle-state" type="checkbox" aria-hidden="true" tabindex="-1">
+    {% endif %}
+{% enddefaultmacro %}
+{% defaultmacro view_source_button(doc) %}
+    {% if show_source and doc.source %}
+        <label class="view-source-button" for="{{ doc.qualname or "mod-" + doc.name }}-view-source"><span>View Source</span></label>
+    {% endif %}
+{% enddefaultmacro %}
+{% defaultmacro view_source_code(doc) %}
+    {% if show_source and doc.source %}
+        {{ doc | highlight }}
+    {% endif %}
+{% enddefaultmacro %}
+{% defaultmacro module_name() %}
+    <h1 class="modulename">
+        {% set parts = module.modulename.split(".") %}
+        {% for part in parts %}
+            {%- set fullname = ".".join(parts[:loop.index]) -%}
+            {%- if fullname in all_modules and fullname != module.modulename -%}
+                <a href="./{{ "../" * loop.revindex0 }}{{ part }}.html">{{ part }}</a>
+            {%- else -%}
+                {{ part }}
+            {%- endif -%}
+            {%- if loop.nextitem -%}
+                <wbr>.
+            {%- endif -%}
+        {% endfor %}
+    </h1>
+{% enddefaultmacro %}
diff --git a/docs/template/search.html.jinja2 b/docs/template/search.html.jinja2
new file mode 100644
index 0000000..c811af2
--- /dev/null
+++ b/docs/template/search.html.jinja2
@@ -0,0 +1,190 @@
+{# This template implements pdoc's search functionality. It is also responsible for lazy-loading the search index.  #}
+{% set rootprefix = "../" * module.modulename.count(".") if module else "" %}
+<script>
+    function escapeHTML(html) {
+        return document.createElement('div').appendChild(document.createTextNode(html)).parentNode.innerHTML;
+    }
+
+    const originalContent = document.querySelector("main.pdoc");
+    let currentContent = originalContent;
+
+    function setContent(innerHTML) {
+        {# Replace the entire page contents. Calling this with an empty argument restores the original page. #}
+        let elem;
+        if (innerHTML) {
+            elem = document.createElement("main");
+            elem.classList.add("pdoc");
+            elem.innerHTML = innerHTML;
+        } else {
+            elem = originalContent;
+        }
+        if (currentContent !== elem) {
+            currentContent.replaceWith(elem);
+            currentContent = elem;
+        }
+    }
+
+    function getSearchTerm() {
+        return (new URL(window.location)).searchParams.get("search");
+    }
+
+    {# the control flow here is: search input -> update location -> onInput #}
+    const searchBox = document.querySelector(".pdoc input[type=search]");
+    searchBox.addEventListener("input", function () {
+        let url = new URL(window.location);
+        if (searchBox.value.trim()) {
+            url.hash = "";
+            url.searchParams.set("search", searchBox.value);
+        } else {
+            url.searchParams.delete("search");
+        }
+        history.replaceState("", "", url.toString());
+        onInput();
+    });
+    window.addEventListener("popstate", onInput);
+
+
+    let search, searchErr;
+
+    async function initialize() {
+        {# Get the search index and compile it if necessary.
+           This function will only be called once. #}
+        try {
+            search = await new Promise((resolve, reject) => {
+                const script = document.createElement("script");
+                script.type = "text/javascript";
+                script.async = true;
+                script.onload = () => resolve(window.pdocSearch);
+                script.onerror = (e) => reject(e);
+                script.src = "{{ rootprefix }}search.js";
+                document.getElementsByTagName("head")[0].appendChild(script);
+            });
+        } catch (e) {
+            console.error("Cannot fetch pdoc search index");
+            searchErr = "Cannot fetch search index.";
+        }
+        onInput();
+
+        {# if the user clicks an anchor link in the navigation, we need to restore the original contents. #}
+        document.querySelector("nav.pdoc").addEventListener("click", e => {
+            if (e.target.hash) {
+                searchBox.value = "";
+                searchBox.dispatchEvent(new Event("input"));
+            }
+        });
+    }
+
+    function onInput() {
+        setContent((() => {
+            const term = getSearchTerm();
+            if (!term) {
+                return null
+            }
+            if (searchErr) {
+                return `<h3>Error: ${searchErr}</h3>`
+            }
+            if (!search) {
+                return "<h3>Searching...</h3>"
+            }
+
+            window.scrollTo({top: 0, left: 0, behavior: 'auto'});
+
+            const results = search(term);
+
+            let html;
+            if (results.length === 0) {
+                html = `No search results for '${escapeHTML(term)}'.`
+            } else {
+                html = `<h4>${results.length} search result${results.length > 1 ? "s" : ""} for '${escapeHTML(term)}'.</h4>`;
+            }
+            for (let result of results.slice(0, 10)) {
+                let doc = result.doc;
+                let url = `{{ rootprefix }}${doc.modulename.replaceAll(".", "/")}.html`;
+                if (doc.qualname) {
+                    url += `#${doc.qualname}`;
+                }
+
+                let heading;
+                switch (result.doc.kind) {
+                    case "function":
+                        if (doc.fullname.endsWith(".__init__")) {
+                            heading = `<span class="name">${doc.fullname.replace(/\.__init__$/, "")}</span>${doc.signature}`;
+                        } else {
+                            heading = `<span class="def">${doc.funcdef}</span> <span class="name">${doc.fullname}</span>${doc.signature}`;
+                        }
+                        break;
+                    case "class":
+                        heading = `<span class="def">class</span> <span class="name">${doc.fullname}</span>`;
+                        if (doc.bases)
+                            heading += `<wbr>(<span class="base">${doc.bases}</span>)`;
+                        heading += `:`;
+                        break;
+                    case "variable":
+                        heading = `<span class="name">${doc.fullname}</span>`;
+                        if (doc.annotation)
+                            heading += `<span class="annotation">${doc.annotation}</span>`;
+                        if (doc.default_value)
+                            heading += `<span class="default_value">${doc.default_value}</span>`;
+                        break;
+                    default:
+                        heading = `<span class="name">${doc.fullname}</span>`;
+                        break;
+                }
+                html += `
+                        <section class="search-result">
+                        <a href="${url}" class="attr ${doc.kind}">${heading}</a>
+                        <div class="docstring">${doc.doc}</div>
+                        </section>
+                    `;
+
+            }
+            return html;
+        })());
+    }
+
+    if (getSearchTerm()) {
+        initialize();
+        searchBox.value = getSearchTerm();
+        onInput();
+    } else {
+        searchBox.addEventListener("focus", initialize, {once: true});
+    }
+
+    {# keyboard navigation for results #}
+    searchBox.addEventListener("keydown", e => {
+        if (["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) {
+            let focused = currentContent.querySelector(".search-result.focused");
+            if (!focused) {
+                currentContent.querySelector(".search-result").classList.add("focused");
+            } else if (
+                e.key === "ArrowDown"
+                && focused.nextElementSibling
+                && focused.nextElementSibling.classList.contains("search-result")
+            ) {
+                focused.classList.remove("focused");
+                focused.nextElementSibling.classList.add("focused");
+                focused.nextElementSibling.scrollIntoView({
+                    behavior: "smooth",
+                    block: "nearest",
+                    inline: "nearest"
+                });
+            } else if (
+                e.key === "ArrowUp"
+                && focused.previousElementSibling
+                && focused.previousElementSibling.classList.contains("search-result")
+            ) {
+                focused.classList.remove("focused");
+                focused.previousElementSibling.classList.add("focused");
+                focused.previousElementSibling.scrollIntoView({
+                    behavior: "smooth",
+                    block: "nearest",
+                    inline: "nearest"
+                });
+            } else if (
+                e.key === "Enter"
+            ) {
+                focused.querySelector("a").click();
+            }
+        }
+    });
+</script>
diff --git a/docs/template/search.js.jinja2 b/docs/template/search.js.jinja2
new file mode 100644
index 0000000..6f1385d
--- /dev/null
+++ b/docs/template/search.js.jinja2
@@ -0,0 +1,51 @@
+{#
+This file contains the search index and is only loaded on demand (when the user focuses the search field).
+We use a JS file instead of JSON as this works with `file://`.
+#}
+window.pdocSearch = (function(){
+    {% include "resources/elasticlunr.min.js" %}
+
+    /** pdoc search index */const docs = {{ search_index | safe }};
+
+    // mirrored in build-search-index.js (part 1)
+    // Also split on html tags. this is a cheap heuristic, but good enough.
+    elasticlunr.tokenizer.setSeperator(/[\s\-.;&_'"=,()]+|<[^>]*>/);
+
+    let searchIndex;
+    if (docs._isPrebuiltIndex) {
+        console.info("using precompiled search index");
+        searchIndex = elasticlunr.Index.load(docs);
+    } else {
+        console.time("building search index");
+        // mirrored in build-search-index.js (part 2)
+        searchIndex = elasticlunr(function () {
+            this.pipeline.remove(elasticlunr.stemmer);
+            this.pipeline.remove(elasticlunr.stopWordFilter);
+            this.addField("qualname");
+            this.addField("fullname");
+            this.addField("annotation");
+            this.addField("default_value");
+            this.addField("signature");
+            this.addField("bases");
+            this.addField("doc");
+            this.setRef("fullname");
+        });
+        for (let doc of docs) {
+            searchIndex.addDoc(doc);
+        }
+        console.timeEnd("building search index");
+    }
+
+    return (term) => searchIndex.search(term, {
+        fields: {
+            qualname: {boost: 4},
+            fullname: {boost: 2},
+            annotation: {boost: 2},
+            default_value: {boost: 2},
+            signature: {boost: 2},
+            bases: {boost: 2},
+            doc: {boost: 1},
+        },
+        expand: true
+    });
+})();
diff --git a/docs/template/theme.css b/docs/template/theme.css
new file mode 100644
index 0000000..c59e9ab
--- /dev/null
+++ b/docs/template/theme.css
@@ -0,0 +1,28 @@
+/* pdoc dark-mode color scheme  */
+:root {
+    --pdoc-background: #ffffff;
+}
+
+.pdoc {
+    --text: hsl(0, 0%, 0%);
+    --muted: hsl(0, 0%, 52%);
+    --link: hsl(212, 100%, 67%);
+    --link-hover: hsl(216, 100%, 61%);
+
+    --active: #ffffff;
+
+    --code: #ffffff;
+    --code-text: hsl(0, 0%, 7%);
+
+    --accent: hsl(0, 0%, 7%);
+    --accent2: hsl(0, 0%, 27%);
+
+    --nav-text: #cccccc;
+    --nav-background: hsl(0, 0%, 22%);
+    --nav-hover: hsl(0, 0%, 32%);
+    --nav-border: hsl(0, 0%, 61%);
+
+    --name: hsl(207, 38%, 43%);
+    --def: hsl(147, 100%, 21%);
+    --annotation: hsl(137, 100%, 38%);
+}
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index e5692d4..b7422ce 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,7 +21,7 @@ write_to = "cliconfig/_version.py"
 Source = "https://github.com/valentingol/cliconfig"
 
 [tool.setuptools.dynamic]
-readme = { file = ["docs/README_pypi.rst"], content-type = "text/x-rst" }
+readme = { file = ["README.md"], content-type = "text/markdown" }
 dependencies = { file = ["requirements.txt"] }
 
 [tool.coverage.run]


Test patch:
diff --git a/tests/unit/processing/test_builtin.py b/tests/unit/processing/test_builtin.py
index 132beb7..37ba6c6 100644
--- a/tests/unit/processing/test_builtin.py
+++ b/tests/unit/processing/test_builtin.py
@@ -194,9 +194,7 @@ def test_process_def() -> None:
     check.equal(flat_config.b, 1 + 1 * 2**2 - 30 // 10)
     check.equal(processing.exprs, {"b": "1 + a.b * a.c**2 - a.b.c.d // 10"})
     flat_config.dict["a.b"] = 4
-    print(processing.exprs)
     flat_config = processing.postmerge(flat_config)
-    print(processing.exprs)
     check.equal(flat_config.b, 1 + 4 * 2**2 - 30 // 10)
     new_dict = {"b@def": "True & a.b.c.d > 20"}
     new_config = Config(new_dict, [processing])