diff --git a/.dockerignore b/.dockerignore
index 0a4bca2c..8906c6fc 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -11,6 +11,9 @@ venv/
 **/*.db
 **/repo
 **/data
+**/data2
+**/data-indexing
+**/data-new-ctags
 
 .git
 .gitignore
diff --git a/README.adoc b/README.adoc
index e071592b..44122e4d 100644
--- a/README.adoc
+++ b/README.adoc
@@ -364,6 +364,8 @@ new project:
  ln -s /srv/git/zephyr.git repo
  export LXR_DATA_DIR=$LXR_PROJ_DIR/data
  export LXR_REPO_DIR=$LXR_PROJ_DIR/repo
+ export LXR_DATA_DIR=$LXR_PROJ_DIR/data
+ export LXR_REPO_DIR=$LXR_PROJ_DIR/repo
 
 Now, go back to the Elixir sources and test that tags are correctly
 extracted:
@@ -371,9 +373,6 @@ extracted:
  ./script.sh list-tags
 
 Depending on how you want to show the available versions on the Elixir pages,
-you may have to apply substitutions to each tag string, for example to add
-a `v` prefix if missing, for consistency with how other project versions are
-shown. You may also decide to ignore specific tags. All this can be done
 by redefining the default `list_tags()` function in a new `projects/<projectname>.sh`
 file. Here's an example (`projects/zephyr.sh` file):
 
@@ -429,8 +428,6 @@ You can then check that Elixir works through your http server.
 
 == Coding style
 
-If you wish to contribute to Elixir's Python code, please
-follow the https://www.python.org/dev/peps/pep-0008/[official coding style for Python].
 
 == How to send patches
 
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 17fbc340..d1a54e70 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -65,6 +65,15 @@ ENV ELIXIR_VERSION=$ELIXIR_VERSION \
     PATH="/usr/local/elixir/utils:/usr/local/elixir/venv/bin:$PATH" \
     PYTHONUNBUFFERED=1
 
-COPY . /usr/local/elixir/
+COPY ./elixir/ /usr/local/elixir
+COPY ./utils/ /usr/local/elixir
+COPY ./projects/ /usr/local/elixir
+COPY ./templates/ /usr/local/elixir
+COPY ./static/ /usr/local/elixir
+COPY ./utils/ /usr/local/elixir
+COPY ./t/ /usr/local/elixir
+COPY samples/ /usr/local/elixir
+COPY *.py /usr/local/elixir
+COPY *.sh /usr/local/elixir
 
 ENTRYPOINT ["/usr/sbin/apache2ctl", "-D", "FOREGROUND"]
diff --git a/elixir/filters/configin.py b/elixir/filters/configin.py
index fa765a2d..f6967454 100755
--- a/elixir/filters/configin.py
+++ b/elixir/filters/configin.py
@@ -1,5 +1,5 @@
 import re
-from .utils import Filter, FilterContext, decode_number, encode_number, filename_without_ext_matches
+from .utils import Filter, FilterContext, decode_number, encode_number, filename_without_ext_matches, format_source_link
 
 # Filters for Config.in includes
 # source "path/file"
@@ -23,7 +23,7 @@ def keep_configin(m):
     def untransform_formatted_code(self, ctx: FilterContext, html: str) -> str:
         def replace_configin(m):
             w = self.configin[decode_number(m.group(1)) - 1]
-            return f'<a href="{ ctx.get_absolute_source_url(w) }">{ w }</a>'
+            return format_source_link(ctx.get_absolute_source_url(w), w)
 
         return re.sub('__KEEPCONFIGIN__([A-J]+)', replace_configin, html, flags=re.MULTILINE)
 
diff --git a/elixir/filters/cppinc.py b/elixir/filters/cppinc.py
index c4ffab9d..517f3ef3 100755
--- a/elixir/filters/cppinc.py
+++ b/elixir/filters/cppinc.py
@@ -1,5 +1,5 @@
 import re
-from .utils import Filter, FilterContext, encode_number, decode_number, extension_matches
+from .utils import Filter, FilterContext, encode_number, decode_number, extension_matches, format_source_link
 
 # Filters for cpp includes like these:
 # #include "file"
@@ -24,8 +24,7 @@ def keep_cppinc(m):
     def untransform_formatted_code(self, ctx: FilterContext, html: str) -> str:
         def replace_cppinc(m):
             w = self.cppinc[decode_number(m.group(1)) - 1]
-            url = ctx.get_relative_source_url(w)
-            return f'<a href="{ url }">{ w }</a>'
+            return format_source_link(ctx.get_relative_source_url(w), w)
 
         return re.sub('__KEEPCPPINC__([A-J]+)', replace_cppinc, html, flags=re.MULTILINE)
 
diff --git a/elixir/filters/cpppathinc.py b/elixir/filters/cpppathinc.py
index 6d3c48c2..964989be 100755
--- a/elixir/filters/cpppathinc.py
+++ b/elixir/filters/cpppathinc.py
@@ -1,5 +1,5 @@
 import re
-from .utils import Filter, FilterContext, encode_number, decode_number, extension_matches
+from .utils import Filter, FilterContext, encode_number, decode_number, extension_matches, format_source_link
 
 # Filters for cpp includes like these:
 # #include <file>
@@ -36,7 +36,7 @@ def untransform_formatted_code(self, ctx: FilterContext, html: str) -> str:
         def replace_cpppathinc(m):
             w = self.cpppathinc[decode_number(m.group(1)) - 1]
             path = f'/include/{ w }'
-            return f'<a href="{ ctx.get_absolute_source_url(path) }">{ w }</a>'
+            return format_source_link(ctx.get_absolute_source_url(path), w)
 
         return re.sub('__KEEPCPPPATHINC__([A-J]+)', replace_cpppathinc, html, flags=re.MULTILINE)
 
diff --git a/elixir/filters/dtsi.py b/elixir/filters/dtsi.py
index 1ef3fba0..44399a54 100755
--- a/elixir/filters/dtsi.py
+++ b/elixir/filters/dtsi.py
@@ -1,5 +1,5 @@
 import re
-from .utils import Filter, FilterContext, encode_number, decode_number, extension_matches
+from .utils import Filter, FilterContext, encode_number, decode_number, extension_matches, format_source_link
 
 # Filters for dts includes as follows:
 # Replaces include directives in dts/dtsi files with links to source
@@ -24,7 +24,7 @@ def keep_dtsi(m):
     def untransform_formatted_code(self, ctx: FilterContext, html: str) -> str:
         def replace_dtsi(m):
             w = self.dtsi[decode_number(m.group(1)) - 1]
-            return f'<a href="{ ctx.get_relative_source_url(w) }">{ w }</a>'
+            return format_source_link(ctx.get_relative_source_url(w), w)
 
         return re.sub('__KEEPDTSI__([A-J]+)', replace_dtsi, html, flags=re.MULTILINE)
 
diff --git a/elixir/filters/kconfig.py b/elixir/filters/kconfig.py
index f21dee37..710700c4 100755
--- a/elixir/filters/kconfig.py
+++ b/elixir/filters/kconfig.py
@@ -1,5 +1,5 @@
 import re
-from .utils import Filter, FilterContext, encode_number, decode_number, filename_without_ext_matches
+from .utils import Filter, FilterContext, encode_number, decode_number, filename_without_ext_matches, format_source_link
 
 # Filters for Kconfig includes
 # Replaces KConfig includes (source keyword) with links to included files
@@ -24,7 +24,7 @@ def keep_kconfig(m):
     def untransform_formatted_code(self, ctx: FilterContext, html: str) -> str:
         def replace_kconfig(m):
             w = self.kconfig[decode_number(m.group(1)) - 1]
-            return f'<a href="{ ctx.get_absolute_source_url(w) }">{ w }</a>'
+            return format_source_link(ctx.get_absolute_source_url(w), w)
 
         return re.sub('__KEEPKCONFIG__([A-J]+)', replace_kconfig, html, flags=re.MULTILINE)
 
diff --git a/elixir/filters/makefiledir.py b/elixir/filters/makefiledir.py
index 73015c73..fc786c6c 100755
--- a/elixir/filters/makefiledir.py
+++ b/elixir/filters/makefiledir.py
@@ -1,6 +1,6 @@
 from os.path import dirname
 import re
-from .utils import Filter, FilterContext, decode_number, encode_number, filename_without_ext_matches
+from .utils import Filter, FilterContext, decode_number, encode_number, filename_without_ext_matches, format_source_link
 
 # Filters for Makefile directory includes as follows:
 # obj-$(VALUE) += dir/
@@ -39,7 +39,7 @@ def replace_makefiledir(m):
 
             fpath = f'{ filedir }{ w }/Makefile'
 
-            return f'<a href="{ ctx.get_absolute_source_url(fpath) }">{ w }/</a>'
+            return format_source_link(ctx.get_absolute_source_url(fpath), w)
 
         return re.sub('__KEEPMAKEFILEDIR__([A-J]+)/', replace_makefiledir, html, flags=re.MULTILINE)
 
diff --git a/elixir/filters/makefiledtb.py b/elixir/filters/makefiledtb.py
index a7fa4163..0b4f4eef 100755
--- a/elixir/filters/makefiledtb.py
+++ b/elixir/filters/makefiledtb.py
@@ -1,6 +1,6 @@
 from os.path import dirname
 import re
-from .utils import Filter, FilterContext, decode_number, encode_number, filename_without_ext_matches
+from .utils import Filter, FilterContext, decode_number, encode_number, filename_without_ext_matches, format_source_link
 
 # Filters for Makefile file includes like these:
 # dtb-y += file.dtb
@@ -30,7 +30,7 @@ def replace_makefiledtb(m):
                 filedir += '/'
 
             npath = f'{ filedir }{ w }.dts'
-            return f'<a href="{ ctx.get_absolute_source_url(npath) }">{ w }.dtb</a>'
+            return format_source_link(ctx.get_absolute_source_url(npath), w+'.dtb')
 
         return re.sub('__KEEPMAKEFILEDTB__([A-J]+)\.dtb', replace_makefiledtb, html, flags=re.MULTILINE)
 
diff --git a/elixir/filters/makefilefile.py b/elixir/filters/makefilefile.py
index be60bef5..09a31873 100755
--- a/elixir/filters/makefilefile.py
+++ b/elixir/filters/makefilefile.py
@@ -1,6 +1,6 @@
 from os.path import dirname
 import re
-from .utils import Filter, FilterContext, decode_number, encode_number, filename_without_ext_matches
+from .utils import Filter, FilterContext, decode_number, encode_number, filename_without_ext_matches, format_source_link
 
 # Filters for files listed in Makefiles
 # path/file
@@ -38,7 +38,7 @@ def replace_makefilefile(m):
                 filedir += '/'
 
             npath = filedir + w
-            return f'<a href="{ ctx.get_absolute_source_url(npath) }">{ w }</a>'
+            return format_source_link(ctx.get_absolute_source_url(npath), w)
 
         return re.sub('__KEEPMAKEFILEFILE__([A-J]+)', replace_makefilefile, html, flags=re.MULTILINE)
 
diff --git a/elixir/filters/makefileo.py b/elixir/filters/makefileo.py
index 6c8f0fe9..e332b31d 100755
--- a/elixir/filters/makefileo.py
+++ b/elixir/filters/makefileo.py
@@ -1,6 +1,6 @@
 from os.path import dirname
 import re
-from .utils import Filter, FilterContext, decode_number, encode_number, filename_without_ext_matches
+from .utils import Filter, FilterContext, decode_number, encode_number, filename_without_ext_matches, format_source_link
 
 # Filters for Makefile file includes like these:
 # file.o
@@ -30,7 +30,7 @@ def replace_makefileo(m):
                 filedir += '/'
 
             npath = f'{ filedir }{ w }.c'
-            return f'<a href="{ ctx.get_absolute_source_url(npath) }">{ w }.o</a>'
+            return format_source_link(ctx.get_absolute_source_url(npath), w+'.o')
 
         return re.sub('__KEEPMAKEFILEO__([A-J]+)\.o', replace_makefileo, html, flags=re.MULTILINE)
 
diff --git a/elixir/filters/makefilesrctree.py b/elixir/filters/makefilesrctree.py
index 4149439f..25531deb 100755
--- a/elixir/filters/makefilesrctree.py
+++ b/elixir/filters/makefilesrctree.py
@@ -1,5 +1,5 @@
 import re
-from .utils import Filter, FilterContext, decode_number, encode_number, filename_without_ext_matches
+from .utils import Filter, FilterContext, decode_number, encode_number, filename_without_ext_matches, format_source_link
 
 # Filters for files listed in Makefiles using $(srctree)
 # $(srctree)/Makefile
@@ -27,8 +27,7 @@ def keep_makefilesrctree(m):
     def untransform_formatted_code(self, ctx: FilterContext, html: str) -> str:
         def replace_makefilesrctree(m):
             w = self.makefilesrctree[decode_number(m.group(1)) - 1]
-            url = ctx.get_absolute_source_url(w)
-            return f'<a href="{ url }">$(srctree)/{ w }</a>'
+            return format_source_link(ctx.get_absolute_source_url(w), f'$(srctree)/{ w }')
 
         return re.sub('__KEEPMAKEFILESRCTREE__([A-J]+)', replace_makefilesrctree, html, flags=re.MULTILINE)
 
diff --git a/elixir/filters/makefilesubdir.py b/elixir/filters/makefilesubdir.py
index e4c6777d..1a097c50 100755
--- a/elixir/filters/makefilesubdir.py
+++ b/elixir/filters/makefilesubdir.py
@@ -1,6 +1,6 @@
 from os.path import dirname
 import re
-from .utils import Filter, FilterContext, decode_number, encode_number, filename_without_ext_matches
+from .utils import Filter, FilterContext, decode_number, encode_number, filename_without_ext_matches, format_source_link
 
 # Filters for Makefile directory includes as follows:
 # subdir-y += dir
@@ -31,7 +31,7 @@ def replace_makefilesubdir(m):
                 filedir += '/'
 
             npath = f'{ filedir }{ w }/Makefile'
-            return f'<a href="{ ctx.get_absolute_source_url(npath) }">{ w }</a>'
+            return format_source_link(ctx.get_absolute_source_url(npath), w)
 
         return re.sub('__KEEPMAKESUBDIR__([A-J]+)', replace_makefilesubdir, html, flags=re.MULTILINE)
 
diff --git a/elixir/filters/utils.py b/elixir/filters/utils.py
index e0d74398..43d7e4a1 100755
--- a/elixir/filters/utils.py
+++ b/elixir/filters/utils.py
@@ -91,3 +91,6 @@ def decode_number(string):
 
     return int(result)
 
+def format_source_link(url: str, label: str) -> str:
+    return f'<a class="source-link" href="{ url }">{ label }</a>'
+
diff --git a/elixir/query.py b/elixir/query.py
index 1e740bf2..97313d3b 100755
--- a/elixir/query.py
+++ b/elixir/query.py
@@ -22,6 +22,7 @@
 from . import lib
 from . import data
 import os
+import sys
 from collections import OrderedDict
 from urllib import parse
 
@@ -175,6 +176,9 @@ def get_versions(self):
     def get_file_type(self, version, path):
         return decode(self.script('get-type', version, path)).strip()
 
+    def get_blob_id(self, version, path):
+        return decode(self.script('get-blob-id', version, path)).strip()
+
     # Returns identifier search results
     def search_ident(self, version, ident, family):
         # DT bindings compatible strings are handled differently
@@ -195,6 +199,25 @@ def get_latest_tag(self):
         # return the oldest tag, even if it does not exist in the database
         return sorted_tags[-1].decode()
 
+    def get_diff(self, version, version_other, path):
+        data = decode(self.script('get-diff', version, version_other, path)).split('\n')
+        result = []
+        for line in data:
+            if len(line) == 0:
+                continue
+            elif line[0] == '+':
+                line_num_left, line_num_right, changes = line[1:].split(':')
+                result.append(('+', int(line_num_left), int(line_num_right), int(changes)))
+            elif line[0] == '-':
+                line_num_left, line_num_right, changes = line[1:].split(':')
+                result.append(('-', int(line_num_left), int(line_num_right), int(changes)))
+            elif line[0] == '=':
+                line_num, changes, other_line_num, other_changes = line[1:].split(':')
+                result.append(('=', int(line_num), int(changes), int(other_line_num), int(other_changes)))
+            else:
+                raise Exception("Invalid line in get-diff: " + line)
+        return result
+
     def get_file_raw(self, version, path):
         return decode(self.script('get-file', version, path))
 
diff --git a/elixir/web.py b/elixir/web.py
index 23b1265c..6dfce2ff 100755
--- a/elixir/web.py
+++ b/elixir/web.py
@@ -1,5 +1,6 @@
 #!/usr/bin/env python3
 
+
 #  This file is part of Elixir, a source code cross-referencer.
 #
 #  Copyright (C) 2017--2020 Mikaƫl Bouillot <mikael.bouillot@bootlin.com>
@@ -25,9 +26,11 @@
 import threading
 import time
 import datetime
+import dataclasses
 from collections import OrderedDict, namedtuple
 from re import search, sub
-from typing import Any, Callable, NamedTuple, Tuple
+from typing import Any, Callable, NamedTuple, Optional, Tuple
+from difflib import SequenceMatcher
 from urllib import parse
 import falcon
 import jinja2
@@ -40,7 +43,7 @@
 from .api import ApiIdentGetterResource
 from .query import get_query
 from .web_utils import ProjectConverter, IdentConverter, validate_version, validate_project, validate_ident, \
-        get_elixir_version_string, get_elixir_repo_url, RequestContext, Config
+        get_elixir_version_string, get_elixir_repo_url, RequestContext, Config, DiffFormater
 
 VERSION_CACHE_DURATION_SECONDS = 2 * 60  # 2 minutes
 ADD_ISSUE_LINK = "https://github.com/bootlin/elixir/issues/new"
@@ -108,7 +111,7 @@ def get_project_error_page(req, resp, exception: ElixirProjectError):
 
         versions_raw = get_versions_cached(query, req.context, project)
         get_url_with_new_version = lambda v: stringify_source_path(project, v, '/')
-        versions, current_version_path = get_versions(versions_raw, get_url_with_new_version, version)
+        versions, current_version_path = get_versions(versions_raw, get_url_with_new_version, None, version)
 
         if current_version_path[2] is None:
             # If details about current version are not available, make base links
@@ -192,6 +195,10 @@ def validate_project_and_version(ctx, project, version):
 def get_source_base_url(project: str, version: str) -> str:
     return f'/{ parse.quote(project, safe="") }/{ parse.quote(version, safe="") }/source'
 
+def stringify_diff_path(project: str, version: str, version_other: str, path: str) -> str:
+    return f'/{ parse.quote(project, safe="") }/{ parse.quote(version, safe="") }/diff/' + \
+        f'{ parse.quote(version_other, safe="") }/{ path }'
+
 # Converts ParsedSourcePath to a string with corresponding URL path
 def stringify_source_path(project: str, version: str, path: str) -> str:
     if not path.startswith('/'):
@@ -249,12 +256,48 @@ def on_get(self, req, resp, project: str, version: str, path: str):
 
         query.close()
 
+# Returns base url of diff pages
+# project and version are assumed to be unquoted
+def get_diff_base_url(project: str, version: str, version_other: str) -> str:
+    return f'/{ parse.quote(project, safe="") }/{ parse.quote(version, safe="") }/diff/{ parse.quote(version_other, safe="") }'
+
 # Handles source URLs without a path, ex. '/u-boot/v2023.10/source'.
 # Note lack of trailing slash
 class SourceWithoutPathResource(SourceResource):
     def on_get(self, req, resp, project: str, version: str):
         return super().on_get(req, resp, project, version, '')
 
+class DiffResource:
+    def on_get(self, req, resp, project: str, version: str, version_other: str, path: str = ''):
+        project, version, query = validate_project_and_version(req.context, project, version)
+        version_other = validate_version(parse.unquote(version_other))
+        if version_other is None or version_other == 'latest':
+            raise ElixirProjectError('Error', 'Invalid other version', project=project, query=query)
+
+        if not path.startswith('/') and len(path) != 0:
+            path = f'/{ path }'
+
+        if path.endswith('/'):
+            resp.status = falcon.HTTP_MOVED_PERMANENTLY
+            resp.location = stringify_source_path(project, version, path)
+            return
+
+        # Check if path contains only allowed characters
+        if not search('^[A-Za-z0-9_/.,+-]*$', path):
+            raise ElixirProjectError('Error', 'Path contains characters that are not allowed',
+                              project=project, version=version, query=query)
+
+        if version == 'latest':
+            version = parse.quote(query.get_latest_tag())
+            resp.status = falcon.HTTP_FOUND
+            resp.location = stringify_source_path(project, version, path)
+            return
+
+        resp.content_type = falcon.MEDIA_HTML
+        resp.status, resp.text = generate_diff_page(req.context, query, project, version, version_other, path)
+
+        query.close()
+
 
 # Returns base url of ident pages
 # project and version assumed unquoted
@@ -399,7 +442,9 @@ def get_projects(basedir: str) -> list[ProjectEntry]:
 
 # Tuple of version name and URL to chosen resource with that version
 # Used to render version list in the sidebar
-VersionEntry = namedtuple('VersionEntry', 'version, url')
+VersionEntry = namedtuple('VersionEntry', 'version, url, diff_url')
+
+VersionPath = namedtuple('VersionPath', 'major, minor, path')
 
 # Takes result of Query.get_versions() and prepares it for the sidebar template.
 #  Returns an OrderedDict with version information and optionally a triple with
@@ -412,10 +457,11 @@ def get_projects(basedir: str) -> list[ProjectEntry]:
 # current_version: string with currently browsed version
 def get_versions(versions: OrderedDict[str, OrderedDict[str, str]],
                  get_url: Callable[[str], str],
-                 current_version: str) -> Tuple[dict[str, dict[str, list[VersionEntry]]], Tuple[str|None, str|None, str|None]]:
+                 get_diff_url: Optional[Callable[[str], str]],
+                 current_version: str) -> Tuple[dict[str, dict[str, list[VersionEntry]]], VersionPath]:
 
     result = OrderedDict()
-    current_version_path = (None, None, None)
+    current_version_path = VersionPath(None, None, None)
     for major, minor_verions in versions.items():
         for minor, patch_versions in minor_verions.items():
             for v in patch_versions:
@@ -423,13 +469,24 @@ def get_versions(versions: OrderedDict[str, OrderedDict[str, str]],
                     result[major] = OrderedDict()
                 if minor not in result[major]:
                     result[major][minor] = []
-                result[major][minor].append(VersionEntry(v, get_url(v)))
+                result[major][minor].append(
+                    VersionEntry(v, get_url(v), get_diff_url(v) if get_diff_url is not None else None)
+                )
                 if v == current_version:
-                    current_version_path = (major, minor, v)
+                    current_version_path = VersionPath(major, minor, v)
 
     return result, current_version_path
 
-# Caches get_versions result in a context object
+def find_version_path(versions: OrderedDict[str, OrderedDict[str, str]],
+                      version: str) -> VersionPath:
+    for major, minor_verions in versions.items():
+        for minor, patch_versions in minor_verions.items():
+            for v in patch_versions:
+                if v == version:
+                    return VersionPath(major, minor, v)
+
+    return VersionPath(None, None, None)
+
 def get_versions_cached(q, ctx, project):
     with ctx.versions_cache_lock:
         if project not in ctx.versions_cache:
@@ -448,14 +505,15 @@ def get_versions_cached(q, ctx, project):
 # project: name of the project
 # version: version of the project
 def get_layout_template_context(q: Query, ctx: RequestContext, get_url_with_new_version: Callable[[str], str],
-                                project: str, version: str) -> dict[str, Any]:
+                                get_diff_url: Optional[Callable[[str], str]], project: str, version: str) -> dict[str, Any]:
     versions_raw = get_versions_cached(q, ctx, project)
-    versions, current_version_path = get_versions(versions_raw, get_url_with_new_version, version)
+    versions, current_version_path = get_versions(versions_raw, get_url_with_new_version, get_diff_url, version)
 
     return {
         'projects': get_projects(ctx.config.project_dir),
         'versions': versions,
         'current_version_path': current_version_path,
+        'other_version_path': (None, None, None),
         'topbar_families': TOPBAR_FAMILIES,
         'elixir_version_string': ctx.config.version_string,
         'elixir_repo_url': ctx.config.repo_url,
@@ -501,7 +559,7 @@ def format_code(filename: str, code: str) -> str:
     lexer.stripnl = False
     formatter = pygments.formatters.HtmlFormatter(
         # Adds line numbers column to output
-        linenos=True,
+        linenos='inline',
         # Wraps line numbers in link (a) tags
         anchorlinenos=True,
         # Wraps each line in a span tag with id='codeline-{line_number}'
@@ -548,20 +606,121 @@ def get_ident_url(ident, ident_family=None):
     html_code_block = format_code(fname, code)
 
     # Replace line numbers by links to the corresponding line in the current file
-    html_code_block = sub('href="#codeline-(\d+)', 'name="L\\1" id="L\\1" href="#L\\1', html_code_block)
+    html_code_block = sub('href="#codeline-(\d+)', 'class="line-link" name="L\\1" id="L\\1" href="#L\\1', html_code_block)
 
     for f in filters:
         html_code_block = f.untransform_formatted_code(filter_ctx, html_code_block)
 
     return html_code_block
 
+def format_diff(filename: str, diff, code: str, code_other: str) -> Tuple[str, str]:
+    import pygments
+    import pygments.lexers
+    import pygments.formatters
+    from pygments.lexers.asm import GasLexer
+    from pygments.lexers.r import SLexer
+
+    try:
+        lexer = pygments.lexers.guess_lexer_for_filename(filename, code)
+        if filename.endswith('.S') and isinstance(lexer, SLexer):
+            lexer = GasLexer()
+    except pygments.util.ClassNotFound:
+        lexer = pygments.lexers.get_lexer_by_name('text')
+
+    lexer.stripnl = False
+
+    formatter = DiffFormater(
+        diff,
+        True,
+        # Adds line numbers column to output
+        linenos='inline',
+        # Wraps line numbers in link (a) tags
+        anchorlinenos=True,
+        # Wraps each line in a span tag with id='codeline-{line_number}'
+        linespans='codeline',
+    )
+
+    formatter_other = DiffFormater(
+        diff,
+        False,
+        # Adds line numbers column to output
+        linenos='inline',
+        # Wraps line numbers in link (a) tags
+        anchorlinenos=True,
+        # Wraps each line in a span tag with id='codeline-{line_number}'
+        linespans='codeline',
+    )
+
+    return pygments.highlight(code, lexer, formatter), pygments.highlight(code_other, lexer, formatter_other)
+
+def generate_diff(q: Query, project: str, version: str, version_other: str, path: str) -> Tuple[str, str]:
+    code = q.get_tokenized_file(version, path)
+    code_other = q.get_tokenized_file(version_other, path)
+    diff = q.get_diff(version, version_other, path)
+
+    _, fname = os.path.split(path)
+    _, extension = os.path.splitext(fname)
+    extension = extension[1:].lower()
+    family = getFileFamily(fname)
+
+    source_base_url = get_source_base_url(project, version)
+    source_base_url_other = get_source_base_url(project, version_other)
+
+    def generate_get_ident_url(version):
+        def get_ident_url(ident, ident_family=None):
+            if ident_family is None:
+                ident_family = family
+            return stringify_ident_path(project, version, ident_family, ident)
+        return get_ident_url
+
+    filter_ctx = FilterContext(
+        q,
+        version,
+        family,
+        path,
+        generate_get_ident_url(version),
+        lambda path: f'{ source_base_url }{ "/" if not path.startswith("/") else "" }{ path }',
+        lambda rel_path: f'{ source_base_url }{ os.path.dirname(path) }/{ rel_path }',
+    )
+
+    filter_ctx_other = dataclasses.replace(filter_ctx,
+        tag=version_other,
+        get_ident_url=generate_get_ident_url(version_other),
+        get_absolute_source_url=lambda path: f'{ source_base_url_other }{ "/" if not path.startswith("/") else "" }{ path }',
+        get_relative_source_url=lambda rel_path: f'{ source_base_url_other }{ os.path.dirname(path) }/{ rel_path }',
+    )
+
+    filters = get_filters(filter_ctx, project)
+    filters_other = get_filters(filter_ctx_other, project)
+
+    # Apply filters
+    for f in filters:
+        code = f.transform_raw_code(filter_ctx, code)
+    for f in filters_other:
+        code_other = f.transform_raw_code(filter_ctx_other, code_other)
+
+    html_code_block, html_code_other_block = format_diff(fname, diff, code, code_other)
+
+    # Replace line numbers by links to the corresponding line in the current file
+    html_code_block = sub('href="#codeline-(\d+)',
+                          'class="line-link" name="L\\1" id="L\\1" href="#L\\1', html_code_block)
+    html_code_other_block = sub('href="#codeline-(\d+)',
+                          'class="line-link" name="OL\\1" id="OL\\1" href="#OL\\1', html_code_other_block)
+
+    for f in filters:
+        html_code_block = f.untransform_formatted_code(filter_ctx, html_code_block)
+    for f in filters_other:
+        html_code_other_block = f.untransform_formatted_code(filter_ctx_other, html_code_other_block)
+
+    return html_code_block, html_code_other_block
+
 # Represents a file entry in git tree
 # type : either tree (directory), blob (file) or symlink
 # name: filename of the file
 # path: path of the file, path to the target in case of symlinks
 # url: absolute URL of the file
 # size: int, file size in bytes, None for directories and symlinks
-DirectoryEntry = namedtuple('DirectoryEntry', 'type, name, path, url, size')
+DirectoryEntry = namedtuple('DirectoryEntry', 'type, name, path, url, size, cls')
 
 # Returns a list of DirectoryEntry objects with information about files in a directory
 # base_url: file URLs will be created by appending file path to this URL. It shouldn't end with a slash
@@ -572,11 +731,11 @@ def get_directory_entries(q: Query, base_url, tag: str, path: str) -> list[Direc
     lines = q.get_dir_contents(tag, path)
 
     for l in lines:
-        type, name, size, perm = l.split(' ')
+        type, name, size, perm, blob_id = l.split(' ')
         file_path = f"{ path }/{ name }"
 
         if type == 'tree':
-            dir_entries.append(DirectoryEntry('tree', name, file_path, f"{ base_url }{ file_path }", None))
+            dir_entries.append(DirectoryEntry('tree', name, file_path, f"{ base_url }{ file_path }", None, None))
         elif type == 'blob':
             # 120000 permission means it's a symlink
             if perm == '120000':
@@ -584,9 +743,9 @@ def get_directory_entries(q: Query, base_url, tag: str, path: str) -> list[Direc
                 link_contents = q.get_file_raw(tag, file_path)
                 link_target_path = os.path.abspath(dir_path + link_contents)
 
-                dir_entries.append(DirectoryEntry('symlink', name, link_target_path, f"{ base_url }{ link_target_path }", size))
+                dir_entries.append(DirectoryEntry('symlink', name, link_target_path, f"{ base_url }{ link_target_path }", size, None))
             else:
-                dir_entries.append(DirectoryEntry('blob', name, file_path, f"{ base_url }{ file_path }", size))
+                dir_entries.append(DirectoryEntry('blob', name, file_path, f"{ base_url }{ file_path }", size, None))
 
     return dir_entries
 
@@ -641,20 +800,185 @@ def generate_source_page(ctx: RequestContext, q: Query,
         title_path = f'{ path_split[-1] } - { "/".join(path_split) } - '
 
     get_url_with_new_version = lambda v: stringify_source_path(project, v, path)
+    get_diff_url = lambda v_other: stringify_diff_path(project, version, v_other, path)
 
     # Create template context
     data = {
-        **get_layout_template_context(q, ctx, get_url_with_new_version, project, version),
+        **get_layout_template_context(q, ctx, get_url_with_new_version, get_diff_url, project, version),
 
         'title_path': title_path,
         'path': path,
         'breadcrumb_urls': breadcrumb_urls,
+        'diff_mode_available': True,
 
         **template_ctx,
     }
 
     return (status, template.render(data))
 
+# Returns a list of DirectoryEntry objects with information about files in a directory
+# base_url: file URLs will be created by appending file path to this URL. It shouldn't end with a slash
+# tag: requested repository tag
+# tag_other: tag to diff with
+# path: path to the directory in the repository
+def diff_directory_entries(q: Query, base_url, tag: str, tag_other: str, path: str) -> list[DirectoryEntry]:
+    dir_entries = []
+
+    names, names_other = {}, {}
+    for line in q.get_dir_contents(tag, path):
+        n = line.split(' ')
+        names[n[1]] = n
+    for line in q.get_dir_contents(tag_other, path):
+        n = line.split(' ')
+        names_other[n[1]] = n
+
+    def dir_sort(name):
+        if name in names and names[name][0] == 'tree':
+            return (1, name)
+        elif name in names_other and names_other[name][0] == 'tree':
+            return (1, name)
+        else:
+            return (2, name)
+
+    all_names = set(names.keys())
+    all_names = all_names.union(names_other.keys())
+    all_names = sorted(all_names, key=dir_sort)
+
+    for name in all_names:
+        data = names.get(name)
+        data_other = names_other.get(name)
+
+        cls = None
+        if data is None and data_other is not None:
+            type, name, size, perm, blob_id = data_other
+            cls = 'added'
+        elif data_other is None and data is not None:
+            type, name, size, perm, blob_id = data
+            cls = 'removed'
+        elif data is not None and data_other is not None:
+            type_old, name, _, _, blob_id = data
+            type, _, size, perm, blob_id_other = data_other
+            if blob_id != blob_id_other or type_old != type:
+                cls = 'changed'
+        else:
+            raise Exception("name does not exist " + name)
+
+        file_path = f"{ path }/{ name }"
+
+        if type == 'tree':
+            dir_entries.append(DirectoryEntry('tree', name, file_path, f"{ base_url }{ file_path }", None, cls))
+        elif type == 'blob':
+            # 120000 permission means it's a symlink
+            if perm == '120000':
+                dir_path = path if path.endswith('/') else path + '/'
+                link_contents = q.get_file_raw(tag, file_path)
+                link_target_path = os.path.abspath(dir_path + link_contents)
+
+                dir_entries.append(DirectoryEntry('symlink', name, link_target_path, f"{ base_url }{ link_target_path }", size, cls))
+            else:
+                dir_entries.append(DirectoryEntry('blob', name, file_path, f"{ base_url }{ file_path }", size, cls))
+
+    return dir_entries
+
+# Generates response (status code and optionally HTML) of the `diff` route
+def generate_diff_page(ctx: RequestContext, q: Query,
+                       project: str, version: str, version_other: str, path: str) -> tuple[int, str]:
+
+    status = falcon.HTTP_OK
+    diff_base_url = get_diff_base_url(project, version, version_other)
+
+    # Generate breadcrumbs
+    path_split = path.split('/')[1:]
+    path_temp = ''
+    breadcrumb_links = []
+    for p in path_split:
+        path_temp += '/'+p
+        breadcrumb_links.append((p, f'{ diff_base_url }{ path_temp }'))
+
+    type = q.get_file_type(version, path)
+    type_other = q.get_file_type(version_other, path)
+    blob_id = q.get_blob_id(version, path)
+    blob_id_other = q.get_blob_id(version_other, path)
+
+    if type == 'tree' or type_other == 'tree':
+        back_path = os.path.dirname(path[:-1])
+        if back_path == '/':
+            back_path = ''
+
+        def generate_warning(type, version):
+            if type == 'blob':
+                return f'{path} is a file in {version}'
+            elif type == '':
+                return f'{path} does not exist in {version}'
+
+        warnings = [generate_warning(type, version), generate_warning(type_other, version)]
+
+        template_ctx = {
+            'dir_entries': diff_directory_entries(q, diff_base_url, version, version_other, path),
+            'back_url': f'{ diff_base_url }{ back_path }' if path != '' else None,
+            'warnings': warnings
+        }
+        template = ctx.jinja_env.get_template('tree.html')
+    elif type == 'blob' or type_other == 'blob':
+        if type == type_other == 'blob' and blob_id != blob_id_other:
+            code, code_other = generate_diff(q, project, version, version_other, path)
+            template_ctx = {
+                'code': code,
+                'code_other': code_other,
+                'other_tag': parse.unquote(version_other),
+            }
+            template = ctx.jinja_env.get_template('diff.html')
+        else:
+            if blob_id == blob_id_other:
+                warning = f'Files are the same in {version} and {version_other}.'
+            else:
+                missing_version = version_other if type == 'blob' else version
+                shown_version = version if type == 'blob' else version_other
+                warning = f'File does not exist, or is not a file in {missing_version}. ({shown_version} displayed)'
+
+            template_ctx = {
+                'code': generate_source(q, project, version if type == 'blob' else version_other, path),
+                'warning': warning
+            }
+            template = ctx.jinja_env.get_template('source.html')
+    else:
+        raise ElixirProjectError('File not found', f'This file does not exist in {version} nor in {version_other}.',
+                                 status=falcon.HTTP_NOT_FOUND,
+                                 query=q, project=project, version=version,
+                                 extra_template_args={'breadcrumb_links': breadcrumb_links})
+
+    # Create titles like this:
+    # root path: "Linux source code (v5.5.6) - Bootlin"
+    # first level path: "arch - Linux source code (v5.5.6) - Bootlin"
+    # deeper paths: "Makefile - arch/um/Makefile - Linux source code (v5.5.6) - Bootlin"
+    if path == '':
+        title_path = ''
+    elif len(path_split) == 1:
+        title_path = f'{ path_split[0] } - '
+    else:
+        title_path = f'{ path_split[-1] } - { "/".join(path_split) } - '
+
+    get_url_with_new_version = lambda v: stringify_source_path(project, v, path)
+    get_diff_url = lambda v_other: stringify_diff_path(project, version, v_other, path)
+
+    # Create template context
+    data = {
+        **get_layout_template_context(q, ctx, get_url_with_new_version, get_diff_url, project, version),
+        **template_ctx,
+
+        'other_version_path': find_version_path(get_versions_cached(q, ctx, project), version_other),
+        'diff_mode_available': True,
+        'diff_checked': True,
+        'diff_exit_url': stringify_source_path(project, version, path),
+
+        'title_path': title_path,
+        'path': path,
+        'breadcrumb_links': breadcrumb_links,
+        'base_url': diff_base_url,
+    }
+
+    return (status, template.render(data))
+
 # Represents line in a file with URL to that line
 LineWithURL = namedtuple('LineWithURL', 'lineno, url')
 
@@ -733,7 +1057,7 @@ def generate_ident_page(ctx: RequestContext, q: Query,
     get_url_with_new_version = lambda v: stringify_ident_path(project, v, family, ident)
 
     data = {
-        **get_layout_template_context(q, ctx, get_url_with_new_version, project, version),
+        **get_layout_template_context(q, ctx, get_url_with_new_version, None, project, version),
 
         'searched_ident': ident,
         'current_family': family,
@@ -812,6 +1136,8 @@ def get_application():
     app.add_route('/{project}/{version}/ident', IdentPostRedirectResource())
     app.add_route('/{project}/{version}/ident/{ident}', IdentWithoutFamilyResource())
     app.add_route('/{project}/{version}/{family}/ident/{ident}', IdentResource())
+    app.add_route('/{project}/{version}/diff/{version_other}/{path:path}', DiffResource())
+    app.add_route('/{project}/{version}/diff/{version_other}', DiffResource())
 
     app.add_route('/acp', AutocompleteResource())
     app.add_route('/api/ident/{project:project}/{ident:ident}', ApiIdentGetterResource())
diff --git a/elixir/web_utils.py b/elixir/web_utils.py
index d2d038b7..bf57e5fc 100644
--- a/elixir/web_utils.py
+++ b/elixir/web_utils.py
@@ -8,6 +8,7 @@
 import jinja2
 
 from .lib import validFamily, run_cmd
+from pygments.formatters import HtmlFormatter
 
 ELIXIR_DIR = os.path.normpath(os.path.dirname(__file__) + "/../")
 ELIXIR_REPO_LINK = 'https://github.com/bootlin/elixir/'
@@ -81,3 +82,94 @@ def convert(self, value: str) -> str|None:
         value = parse.unquote(value)
         return validate_ident(value)
 
+class DiffFormater(HtmlFormatter):
+    def __init__(self, diff, left: bool, *args, **kwargs):
+        self.diff = diff
+        self.left = left
+        super().__init__(*args[2:], **kwargs)
+
+    def get_next_diff_line(self, diff_num, next_diff_line):
+        next_diff = self.diff[diff_num] if len(self.diff) > diff_num else None
+
+        if next_diff is not None:
+            if self.left and (next_diff[0] == '-' or next_diff[0] == '+'):
+                next_diff_line = next_diff[1]
+            elif next_diff[0] == '-' or next_diff[0] == '+':
+                next_diff_line = next_diff[2]
+            elif self.left and next_diff[0] == '=':
+                next_diff_line = next_diff[1]
+            elif next_diff[0] == '=':
+                next_diff_line = next_diff[3]
+            else:
+                raise Exception("invlaid next diff mode")
+
+        return next_diff, diff_num+1, next_diff_line
+
+    def mark_line(self, line, css_class):
+        yield line[0], f'<span class="{css_class}">{line[1]}</span>'
+
+    def mark_lines(self, source, num, css_class):
+        i = 0
+        while i < num:
+            try:
+                t, line = next(source)
+            except StopIteration:
+                break
+            if t == 1:
+                yield t, f'<span class="{css_class}">{line}</span>'
+                i += 1
+            else:
+                yield t, line
+
+    def yield_empty(self, num):
+        for _ in range(num):
+            yield 0, '<span class="diff-line">&nbsp;\n</span>'
+
+    def wrap_diff(self, source):
+        next_diff, diff_num, next_diff_line = self.get_next_diff_line(0, None)
+
+        linenum = 0
+
+        while True:
+            try:
+                line = next(source)
+            except StopIteration:
+                break
+
+            if linenum == next_diff_line:
+                if next_diff is not None:
+                    if self.left and next_diff[0] == '+':
+                        yield from self.yield_empty(next_diff[3])
+                        yield line
+                        linenum += 1
+                    elif next_diff[0] == '+':
+                        yield from self.mark_line(line, 'line-added')
+                        yield from self.mark_lines(source, next_diff[3]-1, 'line-added')
+                        linenum += next_diff[3]
+                    elif self.left and next_diff[0] == '-':
+                        yield from self.mark_line(line, 'line-removed')
+                        yield from self.mark_lines(source, next_diff[3]-1, 'line-removed')
+                        linenum += next_diff[3]
+                    elif next_diff[0] == '-':
+                        yield from self.yield_empty(next_diff[3])
+                        yield line
+                        linenum += 1
+                    elif next_diff[0] == '=':
+                        total = max(next_diff[2], next_diff[4])
+                        to_print = next_diff[2] if self.left else next_diff[4]
+                        yield from self.mark_line(line, 'line-removed' if self.left else 'line-added')
+                        yield from self.mark_lines(source, to_print-1, 'line-removed' if self.left else 'line-added')
+                        yield from self.yield_empty(total-to_print)
+                        linenum += to_print
+                    else:
+                        yield line
+                        linenum += 1
+
+                next_diff, diff_num, next_diff_line = self.get_next_diff_line(diff_num, next_diff_line)
+            else:
+                yield line
+                linenum += 1
+
+    def wrap(self, source):
+        return super().wrap(self.wrap_diff(source))
+
diff --git a/projects/zephyr.sh b/projects/zephyr.sh
index 149b385d..9ada9c94 100644
--- a/projects/zephyr.sh
+++ b/projects/zephyr.sh
@@ -1,7 +1,7 @@
 # Elixir definitions for Zephyr
 
 # Enable DT bindings compatible strings support
-dts_comp_support=1
+dts_comp_support=0
 
 list_tags()
 {
diff --git a/script.sh b/script.sh
index 3bbff2a7..4b209ce2 100755
--- a/script.sh
+++ b/script.sh
@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/bin/bash
 
 #  This file is part of Elixir, a source code cross-referencer.
 #
@@ -79,6 +79,12 @@ get_blob()
     git cat-file blob $opt1
 }
 
+get_blob_id()
+{
+    v=`echo $opt1 | version_rev`
+    git ls-tree --format='%(objectname)' "$v" "`denormalize $opt2`" 2>/dev/null
+}
+
 get_file()
 {
     v=`echo $opt1 | version_rev`
@@ -89,11 +95,24 @@ get_dir()
 {
         v=`echo $opt1 | version_rev`
         git ls-tree -l "$v:`denormalize $opt2`" 2>/dev/null |
-        awk '{print $2" "$5" "$4" "$1}' |
+        awk '{print $2" "$5" "$4" "$1" "$3}' |
         grep -v ' \.' |
         sort -t ' ' -k 1,1r -k 2,2
 }
 
+get_diff()
+{
+    v=`echo $opt1 | version_rev`
+    v_other=`echo $opt2 | version_rev`
+    diff \
+        --unchanged-group-format= \
+        --new-group-format="+%de:%dE:%dN%c'\012'" \
+        --old-group-format="-%de:%dE:%dn%c'\012'" \
+        --changed-group-format="=%de:%dn:%dE:%dN%c'\012'" \
+        <(git cat-file blob "$v:`denormalize $opt3`" 2>/dev/null) \
+        <(git cat-file blob "$v_other:`denormalize $opt3`" 2>/dev/null)
+}
+
 tokenize_file()
 {
     if [ "$opt1" = -b ]; then
@@ -259,6 +278,10 @@ case $cmd in
         get_blob
         ;;
 
+    get-blob-id)
+        get_blob_id
+        ;;
+
     get-file)
         get_file
         ;;
@@ -267,6 +290,10 @@ case $cmd in
         get_dir
         ;;
 
+    get-diff)
+        get_diff
+        ;;
+
     list-blobs)
         list_blobs
         ;;
diff --git a/static/script.js b/static/script.js
index 62a54fc0..129b219c 100644
--- a/static/script.js
+++ b/static/script.js
@@ -113,13 +113,19 @@ function setupSidebarSwitch() {
   });
 }
 
-// Parse and validate line identifier in format L${number}
-function parseLineId(lineId) {
-  if (lineId[0] != "L") {
-    return;
+function getPrefix(lineId) {
+  if (lineId[0] == "L") {
+    return "L";
+  } else if (lineId.startsWith("OL")) {
+    return "OL";
+  } else {
+    return "";
   }
+}
 
-  let lineIdNum = parseInt(lineId.substring(1));
+// Parse and validate line identifier in format L${number}
+function parseLineId(lineId) {
+  const lineIdNum = parseInt(lineId.substring(getPrefix(lineId).length));
   console.assert(!isNaN(lineIdNum), "Invalid line id");
 
   let lineElement = document.getElementById(lineId);
@@ -151,18 +157,24 @@ function parseLineRangeAnchor(hashStr) {
     firstLine = lineTmp;
   }
 
-  return [firstLine, lastLine];
+  return [firstLine, lastLine, getPrefix(hash[0])];
 }
 
 // Highlights line number elements from firstLine to lastLine
-function highlightFromTo(firstLine, lastLine) {
-  const firstLineElement = document.getElementById(`L${ firstLine }`);
-  const lastLineElement = document.getElementById(`L${ lastLine }`);
-
-  const firstCodeLine = document.getElementById(`codeline-${ firstLine }`);
-  const lastCodeLine = document.getElementById(`codeline-${ lastLine }`);
+function highlightFromTo(firstLine, lastLine, prefix='L') {
+  const firstLineElement = document.getElementById(`${ prefix }${ firstLine }`);
+  const lastLineElement = document.getElementById(`${ prefix }${ lastLine }`);
+
+  let firstCodeLine = firstLineElement.parentNode;
+  // handle line-added/changed/deleted wrappers
+  if (firstCodeLine.parentNode.tagName != "PRE") {
+    firstCodeLine = firstCodeLine.parentNode;
+  }
+  let lastCodeLine = lastLineElement.parentNode;
+  if (lastCodeLine.parentNode.tagName != "PRE") {
+    lastCodeLine = lastCodeLine.parentNode;
+  }
 
-  addClassToRangeOfElements(firstLineElement.parentNode, lastLineElement.parentNode, "line-highlight");
   addClassToRangeOfElements(firstCodeLine, lastCodeLine, "line-highlight");
 }
 
@@ -185,14 +197,14 @@ function addClassToRangeOfElements(first, last, class_name) {
 // Sets up listeners on element that contains line numbers to handle
 // shift-clicks for range highlighting
 function setupLineRangeHandlers() {
-  // Check if page contains the element with line numbers
+  // Check if page contains the element with line numbers and code
   // If not, then likely script is not executed in context of the source page
-  const linenodiv = document.querySelector(".linenodiv");
-  if (linenodiv === null) {
+  const lxrcode = document.querySelectorAll(".lxrcode");
+  if (lxrcode === null) {
     return;
   }
 
-  let rangeStartLine, rangeEndLine;
+  let rangeStartLine, rangeEndLine, rangePrefix;
 
   const parseFromHash = () => {
     const highlightedRange = parseLineRangeAnchor(window.location.hash);
@@ -200,10 +212,15 @@ function setupLineRangeHandlers() {
     if (highlightedRange !== undefined) {
       rangeStartLine = highlightedRange[0];
       rangeEndLine = highlightedRange[1];
-      highlightFromTo(rangeStartLine, rangeEndLine);
-      document.getElementById(`L${rangeStartLine}`).scrollIntoView();
+      rangePrefix = highlightedRange[2];
+      highlightFromTo(rangeStartLine, rangeEndLine, rangePrefix);
+      const wrapper = document.querySelector('.wrapper');
+      const offsetTop = document.getElementById(`${rangePrefix}${rangeStartLine}`).offsetTop;
+      wrapper.scrollTop = offsetTop < 100 ? 200 : offsetTop + 100;
     } else if (location.hash !== "" && location.hash[1] === "L") {
       rangeStartLine = parseLineId(location.hash.substring(1));
+    } else if (location.hash !== "" && location.hash.startsWith("OL")) {
+      rangeStartLine = parseLineId(location.hash.substring(2));
     }
   }
 
@@ -214,25 +231,32 @@ function setupLineRangeHandlers() {
 
   parseFromHash();
 
-  linenodiv.addEventListener("click", ev => {
+  lxrcode.forEach(el => el.addEventListener("click", ev => {
     if (ev.ctrlKey || ev.metaKey) {
       return;
     }
+    let el = ev.target;
+    if (el.classList.contains('linenos')) {
+      el = el.parentNode;
+    }
+    if (!el.classList.contains('line-link')) {
+      return;
+    }
     ev.preventDefault();
 
     // Handler is set on the element that contains all line numbers, check if the
     // event is directed at an actual line number element
-    const el = ev.target;
-    if (typeof(el.id) !== "string" || el.id[0] !== "L" || el.tagName !== "A") {
+    if (typeof(el.id) !== "string" || !(el.id[0] == "L" || el.id.startsWith("OL")) || el.tagName !== "A") {
       return;
     }
 
     clearRangeHighlight();
 
-    if (rangeStartLine === undefined || !ev.shiftKey) {
+    if (rangeStartLine === undefined || !ev.shiftKey || rangePrefix != getPrefix(el.id)) {
       rangeStartLine = parseLineId(el.id);
+      rangePrefix = getPrefix(el.id);
       rangeEndLine = undefined;
-      highlightFromTo(rangeStartLine, rangeStartLine);
+      highlightFromTo(rangeStartLine, rangeStartLine, rangePrefix);
       window.location.hash = el.id;
     } else if (ev.shiftKey) {
       if (rangeEndLine === undefined) {
@@ -268,10 +292,10 @@ function setupLineRangeHandlers() {
         }
       }
 
-      highlightFromTo(rangeStartLine, rangeEndLine);
-      window.location.hash = `L${rangeStartLine}-L${rangeEndLine}`;
+      highlightFromTo(rangeStartLine, rangeEndLine, rangePrefix);
+      window.location.hash = `${rangePrefix}${rangeStartLine}-${rangePrefix}${rangeEndLine}`;
     }
-  });
+  }));
 }
 
 /* Other fixes */
diff --git a/static/style.css b/static/style.css
index 77d7703c..6dad8781 100644
--- a/static/style.css
+++ b/static/style.css
@@ -62,7 +62,6 @@ pre {
   font-size: 0.9em;
   line-height: 1.2;
   padding: 0;
-  color: #787878;
 }
 
 table {
@@ -454,14 +453,49 @@ h2 {
   margin: 0;
 }
 .sidebar nav li.active a {
-  color: #eee;
   color: #6d7dd2;
   font-weight: 700;
 }
+.sidebar nav li.active-other a {
+  color: #d2c26d;
+  font-weight: 700;
+}
 .sidebar nav ul {
   padding: 0;
 }
 
+#diff-checkbox-label {
+  color: #888;
+}
+
+#diff-exit {
+  color: #888;
+  padding-left: 1em;
+}
+
+#diff-exit:hover {
+  color: #eee;
+  color: #6d7dd2;
+  font-weight: 700;
+}
+
+#diff-checkbox {
+  padding: 1em;
+  margin-bottom: 0.5em;
+}
+
+#diff-checkbox:checked ~ li .version-link-source {
+  display: none;
+}
+
+.version-link-diff {
+  display: none;
+}
+
+#diff-checkbox:checked ~ li .version-link-diff {
+  display: inline;
+}
+
 /* reference popup */
 
 #reference-popup-wrapper {
@@ -695,6 +729,10 @@ h2 {
   color: gray;
 }
 
+.workspace {
+  position: relative;
+}
+
 /* ident */
 
 .lxrident {
@@ -762,9 +800,18 @@ h2 {
   display: block;
   padding: 0.15em 1.5em;
 }
+.lxrtree tr {
+  opacity: 1;
+}
 .lxrtree tr:hover {
   background: #ddd;
 }
+
+.lxrtree .warning {
+  opacity: 0.5;
+  margin-left: 1.5em;
+}
+
 .tree-icon:before {
   display: inline-block;
   width: 1.5em;
@@ -783,18 +830,40 @@ h2 {
 .tree-icon.icon-back:hover {
   opacity: 1;
 }
+
 .size {
-  opacity: 0.6;
+  color: rgba(0, 0, 0, 0.6);
   min-height: 1.5em; /* line_height + 2 * padding = 1.2em + 2 * 0.15em */
   text-align: right;
 }
 
+.file-added {
+  background: #c2ffad;
+}
 
-/* source code */
+.lxrtree .file-added:hover {
+  background: #c9e9c0;
+}
 
-.lxrcode {
-  position: relative;
+.file-removed {
+  background: #fcc0c0;
 }
+
+.lxrtree .file-removed:hover {
+  background: #e7c8c8;
+}
+
+.file-changed {
+  background: #f8edc3;
+}
+
+.lxrtree .file-changed:hover {
+  background: #e5dfca;
+}
+
+
+/* source code */
+
 .lxrcode pre {
   margin: 0;
   padding-top: 1em;
@@ -806,6 +875,14 @@ h2 {
   vertical-align: top;
 }
 
+.lxrcode .note-banner {
+  width: 100%;
+  text-align: center;
+  padding: 0.3em;
+  background-color: #e9e9e9;
+  color: #555;
+}
+
 .lxrcode,
 .highlighttable,
 .linenodiv,
@@ -813,12 +890,74 @@ h2 {
   height: 100%;
 }
 
+#L1 {
+  padding-top: 1em;
+}
+
+.diff {
+  display: grid;
+  grid-template-columns: 50% 50%;
+}
+
+.diff-line {
+  display: block;
+}
+
+.diff .highlight pre span:first-child {
+}
+
+.diff .lxrcode pre {
+  padding-top: 0;
+}
+
+.line-added {
+  width: 100%;
+  height: 100%;
+  background: #C2FFAD;
+  display: block;
+}
+
+.line-highlight.line-added .line-link {
+  background: #C2FFAD;
+}
+
+.line-removed {
+  width: 100%;
+  height: 100%;
+  background: #fcc0c0;
+  display: block;
+}
+
+.line-highlight.line-removed .line-link {
+  background: #fcc0c0;
+}
+
 span[id^='codeline-'] {
   display: block;
   padding-left: 1em;
+  width: 100%;
+  height: 100%;
 }
 
-span[id^='codeline-'].line-highlight {
+span[id^='codeline-'] {
+  padding-left: unset;
+}
+
+span[id^='codeline-'] span {
+  display: inline-block;
+}
+
+.line-link {
+  background: #e9e9e9;
+  color: #999;
+  padding-left: 1em;
+  padding-right: 1em;
+  margin-right: 1em;
+  width: 100%;
+  height: 100%;
+}
+
+span.line-highlight {
   width: 100%;
   height: 100%;
   background: #f8edc3;
@@ -836,9 +975,6 @@ span[id^='codeline-'].line-highlight {
   scroll-margin: 15vh;
   scroll-margin-top: 15vh;
 }
-.line-highlight {
-  background: #ccc;
-}
 .linenodiv pre a.line-highlight,
 .linenodiv pre span {
   display: inline-block;
@@ -874,6 +1010,29 @@ span[id^='codeline-'].line-highlight {
   border-bottom: 1px dotted #000;
 }
 
+.highlight .source-link,
+.highlight .ident {
+  color: inherit;
+  font-weight: 700;
+  background: linear-gradient(to bottom, #0000 10%, #f4f6ff 10%, #f4f6ff 90%, #0000 90%);
+  border-radius: 0.2em;
+}
+.highlight .line-removed a {
+  background: #fcc0c0;
+}
+.highlight .line-added a {
+  background: #c2ffad;
+}
+.highlight a:hover {
+  border-bottom: 1px dotted #000;
+}
+
+.highlight a .linenos {
+  font-weight: normal;
+  border-radius: 0;
+  background: none;
+}
+
 .code > div {
   height: 100%;
 }
@@ -950,7 +1109,7 @@ span[id^='codeline-'].line-highlight {
 
 /* highlight */
 
-.highlight .code pre {
+.highlight pre {
   color: #000;
   -moz-tab-size: 8;
   -o-tab-size: 8;
@@ -966,76 +1125,76 @@ span[id^='codeline-'].line-highlight {
   hyphens: none;
 }
 
-.highlight .code .hll { background-color: #ffffcc }
-.highlight .code  { background: #ffffff }
-.highlight .code .c { color: slategray; font-style: italic; } /* Comment */
-.highlight .code .err { color: #FF0000; background-color: #FFAAAA } /* Error */
-.highlight .code .k { color: #008800 } /* Keyword */
-.highlight .code .o { color: #666 } /* Operator */
-.highlight .code .ch { color: #888888 } /* Comment.Hashbang */
-.highlight .code .cm { color: slategray; font-style: italic; } /* Comment.Multiline */
-.highlight .code .cp { color: #557799 } /* Comment.Preproc */
-.highlight .code .cpf { color: #888888 } /* Comment.PreprocFile */
-.highlight .code .c1 { color: slategray; font-style: italic; } /* Comment.Single */
-.highlight .code .cs { color: #cc0000 } /* Comment.Special */
-.highlight .code .gd { color: #A00000 } /* Generic.Deleted */
-.highlight .code .ge { font-style: italic } /* Generic.Emph */
-.highlight .code .gr { color: #FF0000 } /* Generic.Error */
-.highlight .code .gh { color: #000080 } /* Generic.Heading */
-.highlight .code .gi { color: #00A000 } /* Generic.Inserted */
-.highlight .code .go { color: #888888 } /* Generic.Output */
-.highlight .code .gp { color: #c65d09 } /* Generic.Prompt */
-.highlight .code .gs { font-weight: bold } /* Generic.Strong */
-.highlight .code .gu { color: #800080 } /* Generic.Subheading */
-.highlight .code .gt { color: #0044DD } /* Generic.Traceback */
-.highlight .code .kc { color: #008800 } /* Keyword.Constant */
-.highlight .code .kd { color: #008800 } /* Keyword.Declaration */
-.highlight .code .kn { color: #008800 } /* Keyword.Namespace */
-.highlight .code .kp { color: #003388 } /* Keyword.Pseudo */
-.highlight .code .kr { color: #008800 } /* Keyword.Reserved */
-.highlight .code .kt { color: #333399 } /* Keyword.Type */
-.highlight .code .m { color: #6600EE } /* Literal.Number */
-.highlight .code .s { color: #de7f00 } /* Literal.String */
-.highlight .code .na { color: #0000CC } /* Name.Attribute */
-.highlight .code .nb { color: #007020 } /* Name.Builtin */
-.highlight .code .nc { color: #BB0066 } /* Name.Class */
-.highlight .code .no { color: #003366 } /* Name.Constant */
-.highlight .code .nd { color: #555555 } /* Name.Decorator */
-.highlight .code .ni { color: #880000 } /* Name.Entity */
-.highlight .code .ne { color: #FF0000 } /* Name.Exception */
-.highlight .code .nf { color: #0066BB } /* Name.Function */
-.highlight .code .nl { color: #997700 } /* Name.Label */
-.highlight .code .nn { color: #0e84b5 } /* Name.Namespace */
-.highlight .code .nt { color: #007700 } /* Name.Tag */
-.highlight .code .nv { color: #996633 } /* Name.Variable */
-.highlight .code .ow { color: #000000 } /* Operator.Word */
-.highlight .code .p { color: #666 } /* Text.Punctuation */
-.highlight .code .w { color: #bbbbbb } /* Text.Whitespace */
-.highlight .code .mb { color: #6600EE } /* Literal.Number.Bin */
-.highlight .code .mf { color: #6600EE } /* Literal.Number.Float */
-.highlight .code .mh { color: #005588 } /* Literal.Number.Hex */
-.highlight .code .mi { color: #0000DD } /* Literal.Number.Integer */
-.highlight .code .mo { color: #4400EE } /* Literal.Number.Oct */
-.highlight .code .sa { color: #de7f00 } /* Literal.String.Affix */
-.highlight .code .sb { color: #de7f00 } /* Literal.String.Backtick */
-.highlight .code .sc { color: #de7f00 } /* Literal.String.Char */
-.highlight .code .dl { color: #de7f00 } /* Literal.String.Delimiter */
-.highlight .code .sd { color: #a29900 } /* Literal.String.Doc */
-.highlight .code .s2 { color: #de7f00 } /* Literal.String.Double */
-.highlight .code .se { color: #a29900 } /* Literal.String.Escape */
-.highlight .code .sh { color: #de7f00 } /* Literal.String.Heredoc */
-.highlight .code .si { color: #de7f00 } /* Literal.String.Interpol */
-.highlight .code .sx { color: #de7f00 } /* Literal.String.Other */
-.highlight .code .sr { color: #a29900 } /* Literal.String.Regex */
-.highlight .code .s1 { color: #de7f00 } /* Literal.String.Single */
-.highlight .code .ss { color: #a29900 } /* Literal.String.Symbol */
-.highlight .code .bp { color: #007020 } /* Name.Builtin.Pseudo */
-.highlight .code .fm { color: #0066BB } /* Name.Function.Magic */
-.highlight .code .vc { color: #336699 } /* Name.Variable.Class */
-.highlight .code .vg { color: #dd7700 } /* Name.Variable.Global */
-.highlight .code .vi { color: #3333BB } /* Name.Variable.Instance */
-.highlight .code .vm { color: #996633 } /* Name.Variable.Magic */
-.highlight .code .il { color: #0000DD } /* Literal.Number.Integer.Long */
+.highlight .hll { background-color: #ffffcc }
+.highlight  { background: #ffffff }
+.highlight .c { color: slategray; font-style: italic; } /* Comment */
+.highlight .err { color: #FF0000; background-color: #FFAAAA } /* Error */
+.highlight .k { color: #008800 } /* Keyword */
+.highlight .o { color: #666 } /* Operator */
+.highlight .ch { color: #888888 } /* Comment.Hashbang */
+.highlight .cm { color: slategray; font-style: italic; } /* Comment.Multiline */
+.highlight .cp { color: #557799 } /* Comment.Preproc */
+.highlight .cpf { color: #888888 } /* Comment.PreprocFile */
+.highlight .c1 { color: slategray; font-style: italic; } /* Comment.Single */
+.highlight .cs { color: #cc0000 } /* Comment.Special */
+.highlight .gd { color: #A00000 } /* Generic.Deleted */
+.highlight .ge { font-style: italic } /* Generic.Emph */
+.highlight .gr { color: #FF0000 } /* Generic.Error */
+.highlight .gh { color: #000080 } /* Generic.Heading */
+.highlight .gi { color: #00A000 } /* Generic.Inserted */
+.highlight .go { color: #888888 } /* Generic.Output */
+.highlight .gp { color: #c65d09 } /* Generic.Prompt */
+.highlight .gs { font-weight: bold } /* Generic.Strong */
+.highlight .gu { color: #800080 } /* Generic.Subheading */
+.highlight .gt { color: #0044DD } /* Generic.Traceback */
+.highlight .kc { color: #008800 } /* Keyword.Constant */
+.highlight .kd { color: #008800 } /* Keyword.Declaration */
+.highlight .kn { color: #008800 } /* Keyword.Namespace */
+.highlight .kp { color: #003388 } /* Keyword.Pseudo */
+.highlight .kr { color: #008800 } /* Keyword.Reserved */
+.highlight .kt { color: #333399 } /* Keyword.Type */
+.highlight .m { color: #6600EE } /* Literal.Number */
+.highlight .s { color: #de7f00 } /* Literal.String */
+.highlight .na { color: #0000CC } /* Name.Attribute */
+.highlight .nb { color: #007020 } /* Name.Builtin */
+.highlight .nc { color: #BB0066 } /* Name.Class */
+.highlight .no { color: #003366 } /* Name.Constant */
+.highlight .nd { color: #555555 } /* Name.Decorator */
+.highlight .ni { color: #880000 } /* Name.Entity */
+.highlight .ne { color: #FF0000 } /* Name.Exception */
+.highlight .nf { color: #0066BB } /* Name.Function */
+.highlight .nl { color: #997700 } /* Name.Label */
+.highlight .nn { color: #0e84b5 } /* Name.Namespace */
+.highlight .nt { color: #007700 } /* Name.Tag */
+.highlight .nv { color: #996633 } /* Name.Variable */
+.highlight .ow { color: #000000 } /* Operator.Word */
+.highlight .p { color: #666 } /* Text.Punctuation */
+.highlight .w { color: #bbbbbb } /* Text.Whitespace */
+.highlight .mb { color: #6600EE } /* Literal.Number.Bin */
+.highlight .mf { color: #6600EE } /* Literal.Number.Float */
+.highlight .mh { color: #005588 } /* Literal.Number.Hex */
+.highlight .mi { color: #0000DD } /* Literal.Number.Integer */
+.highlight .mo { color: #4400EE } /* Literal.Number.Oct */
+.highlight .sa { color: #de7f00 } /* Literal.String.Affix */
+.highlight .sb { color: #de7f00 } /* Literal.String.Backtick */
+.highlight .sc { color: #de7f00 } /* Literal.String.Char */
+.highlight .dl { color: #de7f00 } /* Literal.String.Delimiter */
+.highlight .sd { color: #a29900 } /* Literal.String.Doc */
+.highlight .s2 { color: #de7f00 } /* Literal.String.Double */
+.highlight .se { color: #a29900 } /* Literal.String.Escape */
+.highlight .sh { color: #de7f00 } /* Literal.String.Heredoc */
+.highlight .si { color: #de7f00 } /* Literal.String.Interpol */
+.highlight .sx { color: #de7f00 } /* Literal.String.Other */
+.highlight .sr { color: #a29900 } /* Literal.String.Regex */
+.highlight .s1 { color: #de7f00 } /* Literal.String.Single */
+.highlight .ss { color: #a29900 } /* Literal.String.Symbol */
+.highlight .bp { color: #007020 } /* Name.Builtin.Pseudo */
+.highlight .fm { color: #0066BB } /* Name.Function.Magic */
+.highlight .vc { color: #336699 } /* Name.Variable.Class */
+.highlight .vg { color: #dd7700 } /* Name.Variable.Global */
+.highlight .vi { color: #3333BB } /* Name.Variable.Instance */
+.highlight .vm { color: #996633 } /* Name.Variable.Magic */
+.highlight .il { color: #0000DD } /* Literal.Number.Integer.Long */
 
 
 /* layout */
@@ -1235,7 +1394,7 @@ main {
     position: sticky;
     left: 0;
   }
-  .js .linenos {
+  .js .line-link {
     position: -webkit-sticky;
     position: sticky;
     left: 0;
@@ -1529,7 +1688,7 @@ main {
 }
 .nav-links a:hover {
   color: #fff;
-}
+)}
 
 .social-icons {
   position: absolute;
diff --git a/templates/diff.html b/templates/diff.html
new file mode 100644
index 00000000..bc619224
--- /dev/null
+++ b/templates/diff.html
@@ -0,0 +1,23 @@
+{% extends "layout.html" %}
+
+{% block title %}
+    {{ title_path }} {{ current_project|capitalize }} diff {{ current_tag }} - Bootlin Elixir Cross Referencer
+{% endblock %}
+
+{% block description -%}
+    Elixir Cross Referencer - diff of {{ current_project|capitalize }} {{ current_tag -}} {{- '' if path|length <= 1 else ': ' + path[1:] }}
+{%- endblock %}
+
+{% block main %}
+<div class="diff">
+    <div class="lxrcode">
+        <div class="note-banner">{{ current_tag }}</div>
+        {{ code }}
+    </div>
+    <div class="lxrcode">
+        <div class="note-banner">{{ other_tag }}</div>
+        {{ code_other }}
+    </div>
+</div>
+{% endblock %}
+
diff --git a/templates/sidebar.html b/templates/sidebar.html
index dc9eebce..1c4b78ac 100644
--- a/templates/sidebar.html
+++ b/templates/sidebar.html
@@ -19,10 +19,20 @@ <h3 class="screenreader">Projects</h3>
 
         <h3 class="screenreader">Versions</h3>
         <ul class="versions">
+            {% if diff_mode_available %}
+                <input id="diff-checkbox" type="checkbox" {% if diff_checked %} checked {% endif %} />
+                <label id="diff-checkbox-label" for="diff-checkbox">Diff mode</label>
+                {% if diff_exit_url %}
+                    <a id="diff-exit" href="{{ diff_exit_url }}">Exit diff mode</a>
+                {% endif %}
+            {% endif %}
+
             {% set current_major, current_minor, current_version = current_version_path %}
+            {% set other_major, other_minor, other_version = other_version_path %}
             {% for major, major_versions in (versions|default({})).items() %}
             <li>
-                <span class="{{ 'active' if current_major == major else '' }}">{{ major }}</span>
+                <span class="{{ 'active' if major in (current_major, other_major) else '' }}">{{ major }}</span>
+
                 <ul>
                 {% for minor, minor_versions in major_versions.items() %}
                     {% if minor == minor_versions[0] and minor_versions|length == 1 %}
@@ -31,13 +41,21 @@ <h3 class="screenreader">Versions</h3>
                         </li>
                     {% else %}
                         <li>
-                            <span class="{{ 'active' if minor == current_minor else '' }}">{{ minor }}</span>
+                            <span class="{{ 'active' if minor in (current_minor, other_minor) else '' }}">{{ minor }}</span>
                             <ul>
                                 {% for v in minor_versions %}
-                                    <li class="li-link {{ 'active' if v.version == current_tag else '' }}">
-                                        <a href="{{ v.url }}">
+                                    <li class="li-link
+                                               {{ 'active' if v.version == current_tag else '' }}
+                                               {{ 'active-other' if v.version == other_tag else '' }}
+                                        ">
+                                        <a class="version-link-source" href="{{ v.url }}">
                                             {{ v.version }}
                                         </a>
+                                        {% if v.diff_url is not none %}
+                                            <a class="version-link-diff" href="{{ v.diff_url }}">
+                                                {{ v.version }}
+                                            </a>
+                                        {% endif %}
                                     </li>
                                 {% endfor %}
                             </ul>
diff --git a/templates/source.html b/templates/source.html
index 1ad88544..e06c1bd2 100644
--- a/templates/source.html
+++ b/templates/source.html
@@ -10,6 +10,9 @@
 
 {% block main %}
 <div class="lxrcode">
+    {% if warning %}
+        <div class="note-banner">{{ warning }}</div>
+    {% endif %}
     {{ code }}
 </div>
 {% endblock %}
diff --git a/templates/topbar.html b/templates/topbar.html
index 171b107e..df37ff5c 100644
--- a/templates/topbar.html
+++ b/templates/topbar.html
@@ -1,8 +1,8 @@
 <header class="topbar">
     <div class="breadcrumb">
         <a href="#menu" class="open-menu icon-menu screenreader" title="Open Menu">Open Menu</a>
-        <a class="project" href="{{ source_base_url }}">/</a>
-        {% for name, url in breadcrumb_urls %}
+        <a class="project" href="{{ source_base_url if not base_url else base_url }}">/</a>
+        {% for name, url in breadcrumb_links %}
             <a href="{{ url }}">{{ name }}</a>
             {{ '/' if not loop.last else '' }}
         {% endfor %}
diff --git a/templates/tree.html b/templates/tree.html
index ef9755e4..4339ecda 100644
--- a/templates/tree.html
+++ b/templates/tree.html
@@ -15,6 +15,11 @@
 {% block main %}
 
 <div class="lxrtree">
+    {% for warning in warnings %}
+        {% if warning is not none %}
+            <div class="warning">Note: {{ warning }}</div>
+        {% endif %}
+    {% endfor %}
     <table>
         <tbody>
         {% if back_url is not none %}
@@ -23,10 +28,10 @@
                 <td><a tabindex="-1" class="size" href="{{ back_url }}"></a></td>
             </tr>
         {% endif %}
-        {% for type, name, path, url, size in dir_entries %}
-            <tr>
+        {% for type, name, path, url, size, cls in dir_entries %}
+            <tr class="{{ "file-"+cls if cls is not none else '' -}}">
                 <td>
-                    <a class="tree-icon icon-{{- type if type != 'symlink' else 'blob' -}}"
+                    <a class="tree-icon icon-{{- type if type != 'symlink' else 'blob' }}"
                        href="{{ url }}">
                     {% if type == 'symlink' %}
                         {{ name }} -> {{  path }}