diff --git a/.github/deploy.sh b/.github/deploy.sh
index 5b4b4be4e36..ea118a3b6fc 100644
--- a/.github/deploy.sh
+++ b/.github/deploy.sh
@@ -8,8 +8,8 @@ rm -rf out/master/ || exit 0
 echo "Making the docs for master"
 mkdir out/master/
 cp util/gh-pages/index.html out/master
+cp util/gh-pages/theme.js out/master
 cp util/gh-pages/script.js out/master
-cp util/gh-pages/lints.json out/master
 cp util/gh-pages/style.css out/master
 
 if [[ -n $TAG_NAME ]]; then
diff --git a/.gitignore b/.gitignore
index 181b71a658b..a7c25b29021 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,6 +34,7 @@ out
 
 # gh pages docs
 util/gh-pages/lints.json
+util/gh-pages/index.html
 
 # rustfmt backups
 *.rs.bk
diff --git a/Cargo.toml b/Cargo.toml
index 8b25ba1d8b3..1c1052e6b12 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -39,6 +39,8 @@ toml = "0.7.3"
 walkdir = "2.3"
 filetime = "0.2.9"
 itertools = "0.12"
+pulldown-cmark = "0.11"
+rinja = { version = "0.3", default-features = false, features = ["config"] }
 
 # UI test dependencies
 clippy_utils = { path = "clippy_utils" }
diff --git a/clippy_dev/src/serve.rs b/clippy_dev/src/serve.rs
index cc14cd8dae6..d367fefec61 100644
--- a/clippy_dev/src/serve.rs
+++ b/clippy_dev/src/serve.rs
@@ -19,7 +19,9 @@ pub fn run(port: u16, lint: Option<String>) -> ! {
     });
 
     loop {
-        if mtime("util/gh-pages/lints.json") < mtime("clippy_lints/src") {
+        let index_time = mtime("util/gh-pages/index.html");
+
+        if index_time < mtime("clippy_lints/src") || index_time < mtime("util/gh-pages/index_template.html") {
             Command::new(env::var("CARGO").unwrap_or("cargo".into()))
                 .arg("collect-metadata")
                 .spawn()
diff --git a/rinja.toml b/rinja.toml
new file mode 100644
index 00000000000..a10da6e1f28
--- /dev/null
+++ b/rinja.toml
@@ -0,0 +1,3 @@
+[general]
+dirs = ["util/gh-pages/"]
+whitespace = "suppress"
diff --git a/tests/compile-test.rs b/tests/compile-test.rs
index 8734bd41364..5774e20e0be 100644
--- a/tests/compile-test.rs
+++ b/tests/compile-test.rs
@@ -8,7 +8,10 @@ use clippy_config::ClippyConfiguration;
 use clippy_lints::LintInfo;
 use clippy_lints::declared_lints::LINTS;
 use clippy_lints::deprecated_lints::{DEPRECATED, DEPRECATED_VERSION, RENAMED};
-use serde::{Deserialize, Serialize};
+use pulldown_cmark::{Options, Parser, html};
+use rinja::Template;
+use rinja::filters::Safe;
+use serde::Deserialize;
 use test_utils::IS_RUSTC_TEST_SUITE;
 use ui_test::custom_flags::Flag;
 use ui_test::custom_flags::rustfix::RustfixMode;
@@ -385,6 +388,22 @@ fn ui_cargo_toml_metadata() {
     }
 }
 
+#[derive(Template)]
+#[template(path = "index_template.html")]
+struct Renderer<'a> {
+    lints: &'a Vec<LintMetadata>,
+}
+
+impl Renderer<'_> {
+    fn markdown(input: &str) -> Safe<String> {
+        let parser = Parser::new_ext(input, Options::all());
+        let mut html_output = String::new();
+        html::push_html(&mut html_output, parser);
+        // Oh deer, what a hack :O
+        Safe(html_output.replace("<table", "<table class=\"table\""))
+    }
+}
+
 #[derive(Deserialize)]
 #[serde(untagged)]
 enum DiagnosticOrMessage {
@@ -448,8 +467,11 @@ impl DiagnosticCollector {
 
             metadata.sort_unstable_by(|a, b| a.id.cmp(&b.id));
 
-            let json = serde_json::to_string_pretty(&metadata).unwrap();
-            fs::write("util/gh-pages/lints.json", json).unwrap();
+            fs::write(
+                "util/gh-pages/index.html",
+                Renderer { lints: &metadata }.render().unwrap(),
+            )
+            .unwrap();
         });
 
         (Self { sender }, handle)
@@ -488,7 +510,7 @@ impl Flag for DiagnosticCollector {
     }
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug)]
 struct LintMetadata {
     id: String,
     id_location: Option<&'static str>,
@@ -560,4 +582,14 @@ impl LintMetadata {
             applicability: Applicability::Unspecified,
         }
     }
+
+    fn applicability_str(&self) -> &str {
+        match self.applicability {
+            Applicability::MachineApplicable => "MachineApplicable",
+            Applicability::HasPlaceholders => "HasPlaceholders",
+            Applicability::MaybeIncorrect => "MaybeIncorrect",
+            Applicability::Unspecified => "Unspecified",
+            _ => panic!("needs to update this code"),
+        }
+    }
 }
diff --git a/util/gh-pages/index.html b/util/gh-pages/index.html
deleted file mode 100644
index f3d7e504fdf..00000000000
--- a/util/gh-pages/index.html
+++ /dev/null
@@ -1,330 +0,0 @@
-<!DOCTYPE html>
-<!--
-Welcome to a Clippy's lint list, at least the source code of it. If you are
-interested in contributing to this website checkout `util/gh-pages/index.html`
-inside the rust-clippy repository.
-
-Otherwise, have a great day =^.^=
--->
-<html lang="en">
-<head>
-    <meta charset="UTF-8"/>
-    <meta name="viewport" content="width=device-width, initial-scale=1"/>
-    <meta name="description" content="A collection of lints to catch common mistakes and improve your Rust code.">
-
-    <title>Clippy Lints</title>
-
-    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.6/css/bootstrap.min.css"/>
-    <link id="githubLightHighlight" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/styles/github.min.css" disabled="true" />
-    <link id="githubDarkHighlight" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/styles/github-dark.min.css" disabled="true" />
-
-    <!-- The files are not copied over into the Clippy project since they use the MPL-2.0 License -->
-    <link rel="stylesheet" href="https://rust-lang.github.io/mdBook/css/variables.css"/>
-    <link id="styleHighlight" rel="stylesheet" href="https://rust-lang.github.io/mdBook/highlight.css">
-    <link id="styleNight" rel="stylesheet" href="https://rust-lang.github.io/mdBook/tomorrow-night.css" disabled="true">
-    <link id="styleAyu" rel="stylesheet" href="https://rust-lang.github.io/mdBook/ayu-highlight.css" disabled="true">
-    <link rel="stylesheet" href="style.css">
-</head>
-<body ng-app="clippy" ng-controller="lintList">
-    <div id="settings-dropdown">
-        <div class="settings-icon" tabindex="-1"></div>
-        <div class="settings-menu" tabindex="-1">
-            <div class="setting-radio-name">Theme</div>
-            <select id="theme-choice" onchange="setTheme(this.value, true)">
-                <option value="ayu">Ayu</option>
-                <option value="coal">Coal</option>
-                <option value="light">Light</option>
-                <option value="navy">Navy</option>
-                <option value="rust">Rust</option>
-            </select>
-            <label>
-                <input type="checkbox" id="disable-shortcuts" onchange="changeSetting(this)">
-                <span>Disable keyboard shortcuts</span>
-            </label>
-        </div>
-    </div>
-
-    <div class="container">
-        <div class="page-header">
-            <h1>Clippy Lints</h1>
-        </div>
-
-        <noscript>
-            <div class="alert alert-danger" role="alert">
-                Sorry, this site only works with JavaScript! :(
-            </div>
-        </noscript>
-
-        <div ng-cloak>
-
-            <div class="alert alert-info" role="alert" ng-if="loading">
-                Loading&#x2026;
-            </div>
-            <div class="alert alert-danger" role="alert" ng-if="error">
-                Error loading lints!
-            </div>
-
-            <div class="panel panel-default" ng-show="data">
-                <div class="panel-body row">
-                    <div id="upper-filters" class="col-12 col-md-5">
-                        <div class="btn-group" filter-dropdown>
-                            <button type="button" class="btn btn-default dropdown-toggle">
-                                Lint levels <span class="badge">{{selectedValuesCount(levels)}}</span> <span class="caret"></span>
-                            </button>
-                            <ul class="dropdown-menu">
-                                <li class="checkbox">
-                                    <label ng-click="toggleLevels(true)">
-                                        <input type="checkbox" class="invisible" />
-                                        All
-                                    </label>
-                                </li>
-                                <li class="checkbox">
-                                    <label ng-click="toggleLevels(false)">
-                                        <input type="checkbox" class="invisible" />
-                                        None
-                                    </label>
-                                </li>
-                                <li role="separator" class="divider"></li>
-                                <li class="checkbox" ng-repeat="(level, enabled) in levels">
-                                    <label class="text-capitalize">
-                                        <input type="checkbox" ng-model="levels[level]" />
-                                        {{level}}
-                                    </label>
-                                </li>
-                            </ul>
-                        </div>
-                        <div class="btn-group" filter-dropdown>
-                            <button type="button" class="btn btn-default dropdown-toggle">
-                                Lint groups <span class="badge">{{selectedValuesCount(groups)}}</span> <span class="caret"></span>
-                            </button>
-                            <ul class="dropdown-menu">
-                                <li class="checkbox">
-                                    <label ng-click="toggleGroups(true)">
-                                        <input type="checkbox" class="invisible" />
-                                        All
-                                    </label>
-                                </li>
-                                <li class="checkbox">
-                                    <label ng-click="resetGroupsToDefault()">
-                                        <input type="checkbox" class="invisible" />
-                                        Default
-                                    </label>
-                                </li>
-                                <li class="checkbox">
-                                    <label ng-click="toggleGroups(false)">
-                                        <input type="checkbox" class="invisible" />
-                                        None
-                                    </label>
-                                </li>
-                                <li role="separator" class="divider"></li>
-                                <li class="checkbox" ng-repeat="(group, enabled) in groups">
-                                    <label class="text-capitalize">
-                                        <input type="checkbox" ng-model="groups[group]" />
-                                        {{group}}
-                                    </label>
-                                </li>
-                            </ul>
-                        </div>
-                        <div id="version-filter">
-                            <div class="btn-group" filter-dropdown>
-                                <button type="button" class="btn btn-default dropdown-toggle">
-                                    Version
-                                    <span id="version-filter-count" class="badge">
-                                        {{versionFilterCount(versionFilters)}}
-                                    </span>
-                                    <span class="caret"></span>
-                                </button>
-                                <ul id="version-filter-selector" class="dropdown-menu">
-                                    <li class="checkbox">
-                                        <label ng-click="clearVersionFilters()">
-                                            <input type="checkbox" class="invisible" />
-                                            Clear filters
-                                        </label>
-                                    </li>
-                                    <li role="separator" class="divider"></li>
-                                    <li class="checkbox" ng-repeat="(filter, vars) in versionFilters">
-                                        <label ng-attr-for="filter-{filter}">{{filter}}</label>
-                                        <span>1.</span>
-                                        <input type="number"
-                                                min="29"
-                                                ng-attr-id="filter-{filter}"
-                                                class="version-filter-input form-control filter-input"
-                                                maxlength="2"
-                                                ng-model="versionFilters[filter].minorVersion"
-                                                ng-model-options="{debounce: 50}"
-                                                ng-change="updateVersionFilters()" />
-                                        <span>.0</span>
-                                    </li>
-                                </ul>
-                            </div>
-                        </div>
-                        <div class="btn-group" filter-dropdown>
-                            <button type="button" class="btn btn-default dropdown-toggle">
-                                Applicability <span class="badge">{{selectedValuesCount(applicabilities)}}</span> <span class="caret"></span>
-                            </button>
-                            <ul class="dropdown-menu">
-                                <li class="checkbox">
-                                    <label ng-click="toggleApplicabilities(true)">
-                                        <input type="checkbox" class="invisible" />
-                                        All
-                                    </label>
-                                </li>
-                                <li class="checkbox">
-                                    <label ng-click="toggleApplicabilities(false)">
-                                        <input type="checkbox" class="invisible" />
-                                        None
-                                    </label>
-                                </li>
-                                <li role="separator" class="divider"></li>
-                                <li class="checkbox" ng-repeat="(applicability, enabled) in applicabilities">
-                                    <label class="text-capitalize">
-                                        <input type="checkbox" ng-model="applicabilities[applicability]" />
-                                        {{applicability}}
-                                    </label>
-                                </li>
-                            </ul>
-                        </div>
-                    </div>
-                    <div class="col-12 col-md-5 search-control">
-                        <div class="input-group">
-                            <label class="input-group-addon" id="filter-label" for="search-input">Filter:</label>
-                            <input type="text" class="form-control filter-input" placeholder="Keywords or search string (`S` or `/` to focus)" id="search-input"
-                                ng-model="search" ng-blur="updatePath()" ng-keyup="$event.keyCode == 13 && updatePath()"
-                                ng-model-options="{debounce: 50}" />
-                            <span class="input-group-btn">
-                                <button class="filter-clear btn" type="button" ng-click="search = ''; updatePath();">
-                                    Clear
-                                </button>
-                            </span>
-                        </div>
-                    </div>
-                    <div class="col-12 col-md-2 btn-group expansion-group">
-                        <button title="Collapse All" class="btn btn-default expansion-control" type="button" ng-click="toggleExpansion(data, false)">
-                            <span class="glyphicon glyphicon-collapse-up"></span>
-                        </button>
-                        <button title="Expand All" class="btn btn-default expansion-control" type="button" ng-click="toggleExpansion(data, true)">
-                            <span class="glyphicon glyphicon-collapse-down"></span>
-                        </button>
-                    </div>
-                </div>
-            </div>
-            <!-- The order of the filters should be from most likely to remove a lint to least likely to improve performance. -->
-            <article class="panel panel-default" id="{{lint.id}}" ng-repeat="lint in data | filter:bySearch | filter:byGroups | filter:byLevels | filter:byVersion | filter:byApplicabilities">
-                <header class="panel-heading" ng-click="open[lint.id] = !open[lint.id]">
-                    <h2 class="panel-title">
-                        <div class="panel-title-name">
-                            <span>{{lint.id}}</span>
-                            <a href="#{{lint.id}}" class="anchor label label-default"
-                                ng-click="openLint(lint); $event.preventDefault(); $event.stopPropagation()">&para;</a>
-                            <a href="" id="clipboard-{{lint.id}}" class="anchor label label-default" ng-click="copyToClipboard(lint); $event.stopPropagation()">
-                                &#128203;
-                            </a>
-                        </div>
-
-                        <div class="panel-title-addons">
-                            <span class="label label-lint-group label-default label-group-{{lint.group}}">{{lint.group}}</span>
-
-                            <span class="label label-lint-level label-lint-level-{{lint.level}}">{{lint.level}}</span>
-
-
-                            <span class="label label-doc-folding" ng-show="open[lint.id]">&minus;</span>
-                            <span class="label label-doc-folding" ng-hide="open[lint.id]">&plus;</span>
-                        </div>
-                    </h2>
-                </header>
-
-                <div class="list-group lint-docs" ng-if="open[lint.id]" ng-class="{collapse: true, in: open[lint.id]}">
-                    <div class="list-group-item lint-doc-md" ng-bind-html="lint.docs | markdown"></div>
-                    <div class="lint-additional-info-container">
-                        <!-- Applicability -->
-                        <div class="lint-additional-info-item">
-                            <span> Applicability: </span>
-                            <span class="label label-default label-applicability">{{lint.applicability}}</span>
-                            <a href="https://doc.rust-lang.org/nightly/nightly-rustc/rustc_lint_defs/enum.Applicability.html#variants">(?)</a>
-                        </div>
-                        <!-- Clippy version -->
-                        <div class="lint-additional-info-item">
-                            <span>{{lint.group == "deprecated" ? "Deprecated" : "Added"}} in: </span>
-                            <span class="label label-default label-version">{{lint.version}}</span>
-                        </div>
-                        <!-- Open related issues -->
-                        <div class="lint-additional-info-item">
-                            <a href="https://github.com/rust-lang/rust-clippy/issues?q=is%3Aissue+{{lint.id}}">Related Issues</a>
-                        </div>
-                        <!-- Jump to source -->
-                        <div class="lint-additional-info-item" ng-if="lint.id_location">
-                            <a href="https://github.com/rust-lang/rust-clippy/blob/{{docVersion}}/{{lint.id_location}}">View Source</a>
-                        </div>
-                    </div>
-                </div>
-            </article>
-        </div>
-    </div>
-
-    <a
-        aria-label="View source on GitHub"
-        class="github-corner"
-        href="https://github.com/rust-lang/rust-clippy"
-        rel="noopener noreferrer"
-        target="_blank"
-    >
-        <svg
-            width="80"
-            height="80"
-            viewBox="0 0 250 250"
-            style="position: absolute; top: 0; border: 0; right: 0"
-            aria-hidden="true"
-        >
-            <path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z" fill="var(--theme-color)"></path>
-            <path
-                d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
-                fill="currentColor"
-                style="transform-origin: 130px 106px"
-                class="octo-arm"
-            ></path>
-            <path
-                d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
-                fill="currentColor"
-                class="octo-body"
-            ></path>
-        </svg>
-        <style>
-            .github-corner svg {
-                fill: var(--fg);
-                color: var(--bg);
-            }
-            .github-corner:hover .octo-arm {
-                animation: octocat-wave 560ms ease-in-out;
-            }
-            @keyframes octocat-wave {
-                0%,
-                100% {
-                    transform: rotate(0);
-                }
-                20%,
-                60% {
-                    transform: rotate(-25deg);
-                }
-                40%,
-                80% {
-                    transform: rotate(10deg);
-                }
-            }
-            @media (max-width: 500px) {
-                .github-corner:hover .octo-arm {
-                    animation: none;
-                }
-                .github-corner .octo-arm {
-                    animation: octocat-wave 560ms ease-in-out;
-                }
-            }
-        </style>
-    </a>
-
-    <script src="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/12.3.2/markdown-it.min.js"></script>
-    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/highlight.min.js"></script>
-    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/languages/rust.min.js"></script>
-    <script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.12/angular.min.js"></script>
-    <script src="script.js"></script>
-</body>
-</html>
diff --git a/util/gh-pages/index_template.html b/util/gh-pages/index_template.html
new file mode 100644
index 00000000000..663ef1fbd31
--- /dev/null
+++ b/util/gh-pages/index_template.html
@@ -0,0 +1,232 @@
+<!DOCTYPE html>
+<!--
+Welcome to a Clippy's lint list, at least the source code of it. If you are
+interested in contributing to this website checkout `util/gh-pages/index_template.html`
+inside the rust-clippy repository.
+
+Otherwise, have a great day =^.^=
+-->
+<html lang="en"> {# #}
+<head> {# #}
+    <meta charset="UTF-8"/> {# #}
+    <meta name="viewport" content="width=device-width, initial-scale=1"/> {# #}
+    <meta name="description" content="A collection of lints to catch common mistakes and improve your Rust code."> {# #}
+
+    <title>Clippy Lints</title> {# #}
+
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.6/css/bootstrap.min.css"/> {# #}
+    <link id="githubLightHighlight" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/styles/github.min.css" disabled="true" /> {# #}
+    <link id="githubDarkHighlight" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/styles/github-dark.min.css" disabled="true" /> {# #}
+
+    <!-- The files are not copied over into the Clippy project since they use the MPL-2.0 License -->
+    <link rel="stylesheet" href="https://rust-lang.github.io/mdBook/css/variables.css"/> {# #}
+    <link id="styleHighlight" rel="stylesheet" href="https://rust-lang.github.io/mdBook/highlight.css"> {# #}
+    <link id="styleNight" rel="stylesheet" href="https://rust-lang.github.io/mdBook/tomorrow-night.css" disabled="true"> {# #}
+    <link id="styleAyu" rel="stylesheet" href="https://rust-lang.github.io/mdBook/ayu-highlight.css" disabled="true"> {# #}
+    <link rel="stylesheet" href="style.css"> {# #}
+</head> {# #}
+<body> {# #}
+    <script src="theme.js"></script> {# #}
+    <div id="settings-dropdown"> {# #}
+        <button class="settings-icon" tabindex="-1"></button> {# #}
+        <div class="settings-menu" tabindex="-1"> {# #}
+            <div class="setting-radio-name">Theme</div> {# #}
+            <select id="theme-choice" onchange="setTheme(this.value, true)"> {# #}
+                <option value="ayu">Ayu</option> {# #}
+                <option value="coal">Coal</option> {# #}
+                <option value="light">Light</option> {# #}
+                <option value="navy">Navy</option> {# #}
+                <option value="rust">Rust</option> {# #}
+            </select> {# #}
+            <label> {# #}
+                <input type="checkbox" id="disable-shortcuts" onchange="changeSetting(this)"> {#+ #}
+                <span>Disable keyboard shortcuts</span> {# #}
+            </label> {# #}
+        </div> {# #}
+    </div> {# #}
+
+    <div class="container"> {# #}
+        <div class="page-header"> {# #}
+            <h1>Clippy Lints</h1> {# #}
+        </div> {# #}
+
+        <noscript> {# #}
+            <div class="alert alert-danger" role="alert"> {# #}
+                Sorry, this site only works with JavaScript! :( {# #}
+            </div> {# #}
+        </noscript> {# #}
+
+        <div> {# #}
+            <div class="panel panel-default"> {# #}
+                <div class="panel-body row"> {# #}
+                    <div id="upper-filters" class="col-12 col-md-5"> {# #}
+                        <div class="btn-group" id="lint-levels" tabindex="-1"> {# #}
+                            <button type="button" class="btn btn-default dropdown-toggle"> {# #}
+                                Lint levels <span class="badge">4</span> <span class="caret"></span> {# #}
+                            </button> {# #}
+                            <ul class="dropdown-menu" id="lint-levels-selector"> {# #}
+                                <li class="checkbox"> {# #}
+                                    <button onclick="toggleElements('levels_filter', true)">All</button> {# #}
+                                </li> {# #}
+                                <li class="checkbox"> {# #}
+                                    <button onclick="toggleElements('levels_filter', false)">None</button> {# #}
+                                </li> {# #}
+                                <li role="separator" class="divider"></li> {# #}
+                            </ul> {# #}
+                        </div> {# #}
+                        <div class="btn-group" id="lint-groups" tabindex="-1"> {# #}
+                            <button type="button" class="btn btn-default dropdown-toggle"> {# #}
+                                Lint groups <span class="badge">9</span> <span class="caret"></span> {# #}
+                            </button> {# #}
+                            <ul class="dropdown-menu" id="lint-groups-selector"> {# #}
+                                <li class="checkbox"> {# #}
+                                    <button onclick="toggleElements('groups_filter', true)">All</button> {# #}
+                                </li> {# #}
+                                <li class="checkbox"> {# #}
+                                    <button onclick="resetGroupsToDefault()">Default</button> {# #}
+                                </li> {# #}
+                                <li class="checkbox"> {# #}
+                                    <button onclick="toggleElements('groups_filter', false)">None</button> {# #}
+                                </li> {# #}
+                                <li role="separator" class="divider"></li> {# #}
+                            </ul> {# #}
+                        </div> {# #}
+                        <div class="btn-group" id="version-filter" tabindex="-1"> {# #}
+                            <button type="button" class="btn btn-default dropdown-toggle"> {# #}
+                                Version {#+ #}
+                                <span id="version-filter-count" class="badge">0</span> {#+ #}
+                                <span class="caret"></span> {# #}
+                            </button> {# #}
+                            <ul id="version-filter-selector" class="dropdown-menu"> {# #}
+                                <li class="checkbox"> {# #}
+                                    <button onclick="clearVersionFilters()">Clear filters</button> {# #}
+                                </li> {# #}
+                                <li role="separator" class="divider"></li> {# #}
+                            </ul> {# #}
+                        </div> {# #}
+                        <div class="btn-group", id="lint-applicabilities" tabindex="-1"> {# #}
+                            <button type="button" class="btn btn-default dropdown-toggle"> {# #}
+                                Applicability {#+ #}
+                                <span class="badge">4</span> {#+ #}
+                                <span class="caret"></span> {# #}
+                            </button> {# #}
+                            <ul class="dropdown-menu" id="lint-applicabilities-selector"> {# #}
+                                <li class="checkbox"> {# #}
+                                    <button onclick="toggleElements('applicabilities_filter', true)">All</button> {# #}
+                                </li> {# #}
+                                <li class="checkbox"> {# #}
+                                    <button onclick="toggleElements('applicabilities_filter', false)">None</button> {# #}
+                                </li> {# #}
+                                <li role="separator" class="divider"></li> {# #}
+                            </ul> {# #}
+                        </div> {# #}
+                    </div> {# #}
+                    <div class="col-12 col-md-5 search-control"> {# #}
+                        <div class="input-group"> {# #}
+                            <label class="input-group-addon" id="filter-label" for="search-input">Filter:</label> {# #}
+                            <input type="text" class="form-control filter-input" placeholder="Keywords or search string (`S` or `/` to focus)" id="search-input" /> {# #}
+                            <span class="input-group-btn"> {# #}
+                                <button class="filter-clear btn" type="button" onclick="searchState.clearInput(event)"> {# #}
+                                    Clear {# #}
+                                </button> {# #}
+                            </span> {# #}
+                        </div> {# #}
+                    </div> {# #}
+                    <div class="col-12 col-md-2 btn-group expansion-group"> {# #}
+                        <button title="Collapse All" class="btn btn-default expansion-control" type="button" onclick="toggleExpansion(false)"> {# #}
+                            <span class="glyphicon glyphicon-collapse-up"></span> {# #}
+                        </button> {# #}
+                        <button title="Expand All" class="btn btn-default expansion-control" type="button" onclick="toggleExpansion(true)"> {# #}
+                            <span class="glyphicon glyphicon-collapse-down"></span> {# #}
+                        </button> {# #}
+                    </div> {# #}
+                </div> {# #}
+            </div>
+            {% for lint in lints %}
+                <article class="panel panel-default collapsed" id="{{lint.id}}"> {# #}
+                    <header class="panel-heading" onclick="expandLint('{{lint.id}}')"> {# #}
+                        <h2 class="panel-title"> {# #}
+                            <div class="panel-title-name" id="lint-{{lint.id}}"> {# #}
+                                <span>{{lint.id}}</span> {#+ #}
+                                <a href="#{{lint.id}}" class="anchor label label-default" onclick="openLint(event)">&para;</a> {#+ #}
+                                <a href="" class="anchor label label-default" onclick="copyToClipboard(event)"> {# #}
+                                    &#128203; {# #}
+                                </a> {# #}
+                            </div> {# #}
+
+                            <div class="panel-title-addons"> {# #}
+                                <span class="label label-lint-group label-default label-group-{{lint.group}}">{{lint.group}}</span> {#+ #}
+
+                                <span class="label label-lint-level label-lint-level-{{lint.level}}">{{lint.level}}</span> {#+ #}
+
+                                <span class="label label-doc-folding">&plus;</span> {# #}
+                            </div> {# #}
+                        </h2> {# #}
+                    </header> {# #}
+
+                    <div class="list-group lint-docs"> {# #}
+                        <div class="list-group-item lint-doc-md">{{Self::markdown(lint.docs)}}</div> {# #}
+                        <div class="lint-additional-info-container">
+                            {# Applicability #}
+                            <div class="lint-additional-info-item"> {# #}
+                                <span> Applicability: </span> {# #}
+                                <span class="label label-default label-applicability">{{ lint.applicability_str() }}</span> {# #}
+                                <a href="https://doc.rust-lang.org/nightly/nightly-rustc/rustc_lint_defs/enum.Applicability.html#variants">(?)</a> {# #}
+                            </div>
+                            {# Clippy version #}
+                            <div class="lint-additional-info-item"> {# #}
+                                <span>{% if lint.group == "deprecated" %}Deprecated{% else %} Added{% endif +%} in: </span> {# #}
+                                <span class="label label-default label-version">{{lint.version}}</span> {# #}
+                            </div>
+                            {# Open related issues #}
+                            <div class="lint-additional-info-item"> {# #}
+                                <a href="https://github.com/rust-lang/rust-clippy/issues?q=is%3Aissue+{{lint.id}}">Related Issues</a> {# #}
+                            </div>
+
+                            {# Jump to source #}
+                            {% if let Some(id_location) = lint.id_location %}
+                                <div class="lint-additional-info-item"> {# #}
+                                    <a href="https://github.com/rust-lang/rust-clippy/blob/master/clippy_lints/{{id_location}}">View Source</a> {# #}
+                                </div>
+                            {% endif %}
+                        </div> {# #}
+                    </div> {# #}
+                </article>
+            {% endfor %}
+        </div> {# #}
+    </div> {# #}
+
+    <a {#+ #}
+        aria-label="View source on GitHub" {#+ #}
+        class="github-corner" {#+ #}
+        href="https://github.com/rust-lang/rust-clippy" {#+ #}
+        rel="noopener noreferrer" {#+ #}
+        target="_blank" {# #}
+    > {# #}
+        <svg {#+ #}
+            width="80" {#+ #}
+            height="80" {#+ #}
+            viewBox="0 0 250 250" {#+ #}
+            style="position: absolute; top: 0; border: 0; right: 0" {#+ #}
+            aria-hidden="true" {# #}
+        > {# #}
+            <path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z" fill="var(--theme-color)"></path> {# #}
+            <path {#+ #}
+                d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" {#+ #}
+                fill="currentColor" {#+ #}
+                style="transform-origin: 130px 106px" {#+ #}
+                class="octo-arm" {# #}
+            ></path> {# #}
+            <path {#+ #}
+                d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" {#+ #}
+                fill="currentColor" {#+ #}
+                class="octo-body" {# #}
+            ></path> {# #}
+        </svg> {# #}
+    </a> {# #}
+
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/highlight.min.js"></script> {# #}
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/languages/rust.min.js"></script> {# #}
+    <script src="script.js"></script> {# #}
+</body> {# #}
+</html> {# #}
diff --git a/util/gh-pages/script.js b/util/gh-pages/script.js
index 1a5330bc0e5..cc22a39b3d1 100644
--- a/util/gh-pages/script.js
+++ b/util/gh-pages/script.js
@@ -1,556 +1,82 @@
-(function () {
-    const md = window.markdownit({
-        html: true,
-        linkify: true,
-        typographer: true,
-        highlight: function (str, lang) {
-            if (lang && hljs.getLanguage(lang)) {
-                try {
-                    return '<pre class="hljs"><code>' +
-                        hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
-                        '</code></pre>';
-                } catch (__) {}
-            }
-
-            return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
+window.searchState = {
+    timeout: null,
+    inputElem: document.getElementById("search-input"),
+    lastSearch: '',
+    clearInput: () => {
+        searchState.inputElem.value = "";
+        searchState.filterLints();
+    },
+    clearInputTimeout: () => {
+        if (searchState.timeout !== null) {
+            clearTimeout(searchState.timeout);
+            searchState.timeout = null
         }
-    });
+    },
+    resetInputTimeout: () => {
+        searchState.clearInputTimeout();
+        setTimeout(searchState.filterLints, 50);
+    },
+    filterLints: () => {
+        function matchesSearch(lint, terms, searchStr) {
+            // Search by id
+            if (lint.elem.id.indexOf(searchStr) !== -1) {
+                return true;
+            }
+            // Search the description
+            // The use of `for`-loops instead of `foreach` enables us to return early
+            const docsLowerCase = lint.elem.textContent.toLowerCase();
+            for (const term of terms) {
+                // This is more likely and will therefore be checked first
+                if (docsLowerCase.indexOf(term) !== -1) {
+                    return true;
+                }
 
-    function scrollToLint(lintId) {
-        const target = document.getElementById(lintId);
-        if (!target) {
+                if (lint.elem.id.indexOf(term) !== -1) {
+                    return true;
+                }
+
+                return false;
+            }
+            return true;
+        }
+
+        searchState.clearInputTimeout();
+
+        let searchStr = searchState.inputElem.value.trim().toLowerCase();
+        if (searchStr.startsWith("clippy::")) {
+            searchStr = searchStr.slice(8);
+        }
+        if (searchState.lastSearch === searchStr) {
             return;
         }
-        target.scrollIntoView();
-    }
+        searchState.lastSearch = searchStr;
+        const terms = searchStr.split(" ");
+        const cleanedSearchStr = searchStr.replaceAll("-", "_");
 
-    function scrollToLintByURL($scope, $location) {
-        const removeListener = $scope.$on('ngRepeatFinished', function (ngRepeatFinishedEvent) {
-            scrollToLint($location.path().substring(1));
-            removeListener();
-        });
-    }
-
-    function selectGroup($scope, selectedGroup) {
-        const groups = $scope.groups;
-        for (const group in groups) {
-            if (groups.hasOwnProperty(group)) {
-                groups[group] = group === selectedGroup;
+        for (const lint of filters.getAllLints()) {
+            lint.searchFilteredOut = !matchesSearch(lint, terms, cleanedSearchStr);
+            if (lint.filteredOut) {
+                continue;
+            }
+            if (lint.searchFilteredOut) {
+                lint.elem.style.display = "none";
+            } else {
+                lint.elem.style.display = "";
             }
         }
-    }
-
-    angular.module("clippy", [])
-        .filter('markdown', function ($sce) {
-            return function (text) {
-                return $sce.trustAsHtml(
-                    md.render(text || '')
-                        // Oh deer, what a hack :O
-                        .replace('<table', '<table class="table"')
-                );
-            };
-        })
-        .directive('filterDropdown', function ($document) {
-            return {
-                restrict: 'A',
-                link: function ($scope, $element, $attr) {
-                    $element.bind('click', function (event) {
-                        if (event.target.closest('button')) {
-                            $element.toggleClass('open');
-                        } else {
-                            $element.addClass('open');
-                        }
-                        $element.addClass('open-recent');
-                    });
-
-                    $document.bind('click', function () {
-                        if (!$element.hasClass('open-recent')) {
-                            $element.removeClass('open');
-                        }
-                        $element.removeClass('open-recent');
-                    })
-                }
-            }
-        })
-        .directive('onFinishRender', function ($timeout) {
-            return {
-                restrict: 'A',
-                link: function (scope, element, attr) {
-                    if (scope.$last === true) {
-                        $timeout(function () {
-                            scope.$emit(attr.onFinishRender);
-                        });
-                    }
-                }
-            };
-        })
-        .controller("lintList", function ($scope, $http, $location, $timeout) {
-            // Level filter
-            const LEVEL_FILTERS_DEFAULT = {allow: true, warn: true, deny: true, none: true};
-            $scope.levels = { ...LEVEL_FILTERS_DEFAULT };
-            $scope.byLevels = function (lint) {
-                return $scope.levels[lint.level];
-            };
-
-            const GROUPS_FILTER_DEFAULT = {
-                cargo: true,
-                complexity: true,
-                correctness: true,
-                nursery: true,
-                pedantic: true,
-                perf: true,
-                restriction: true,
-                style: true,
-                suspicious: true,
-                deprecated: false,
-            }
-
-            $scope.groups = {
-                ...GROUPS_FILTER_DEFAULT
-            };
-
-            $scope.versionFilters = {
-                "≥": {enabled: false, minorVersion: null },
-                "≤": {enabled: false, minorVersion: null },
-                "=": {enabled: false, minorVersion: null },
-            };
-
-            // Map the versionFilters to the query parameters in a way that is easier to work with in a URL
-            const versionFilterKeyMap = {
-                "≥": "gte",
-                "≤": "lte",
-                "=": "eq"
-            };
-            const reverseVersionFilterKeyMap = Object.fromEntries(
-                Object.entries(versionFilterKeyMap).map(([key, value]) => [value, key])
-            );
-
-            const APPLICABILITIES_FILTER_DEFAULT = {
-                MachineApplicable: true,
-                MaybeIncorrect: true,
-                HasPlaceholders: true,
-                Unspecified: true,
-            };
-
-            $scope.applicabilities = {
-                ...APPLICABILITIES_FILTER_DEFAULT
-            }
-
-            // loadFromURLParameters retrieves filter settings from the URL parameters and assigns them
-            // to corresponding $scope variables.
-            function loadFromURLParameters() {
-                // Extract parameters from URL
-                const urlParameters = $location.search();
-
-                // Define a helper function that assigns URL parameters to a provided scope variable
-                const handleParameter = (parameter, scopeVariable, defaultValues) => {
-                    if (urlParameters[parameter]) {
-                        const items = urlParameters[parameter].split(',');
-                        for (const key in scopeVariable) {
-                            if (scopeVariable.hasOwnProperty(key)) {
-                                scopeVariable[key] = items.includes(key);
-                            }
-                        }
-                    } else if (defaultValues) {
-                        for (const key in defaultValues) {
-                            if (scopeVariable.hasOwnProperty(key)) {
-                                scopeVariable[key] = defaultValues[key];
-                            }
-                        }
-                    }
-                };
-
-                handleParameter('levels', $scope.levels, LEVEL_FILTERS_DEFAULT);
-                handleParameter('groups', $scope.groups, GROUPS_FILTER_DEFAULT);
-                handleParameter('applicabilities', $scope.applicabilities, APPLICABILITIES_FILTER_DEFAULT);
-
-                // Handle 'versions' parameter separately because it needs additional processing
-                if (urlParameters.versions) {
-                    const versionFilters = urlParameters.versions.split(',');
-                    for (const versionFilter of versionFilters) {
-                        const [key, minorVersion] = versionFilter.split(':');
-                        const parsedMinorVersion = parseInt(minorVersion);
-
-                        // Map the key from the URL parameter to its original form
-                        const originalKey = reverseVersionFilterKeyMap[key];
-
-                        if (originalKey in $scope.versionFilters && !isNaN(parsedMinorVersion)) {
-                            $scope.versionFilters[originalKey].enabled = true;
-                            $scope.versionFilters[originalKey].minorVersion = parsedMinorVersion;
-                        }
-                    }
-                }
-
-                // Load the search parameter from the URL path
-                const searchParameter = $location.path().substring(1); // Remove the leading slash
-                if (searchParameter) {
-                    $scope.search = searchParameter;
-                    $scope.open[searchParameter] = true;
-                    scrollToLintByURL($scope, $location);
-                }
-            }
-
-            // updateURLParameter updates the URL parameter with the given key to the given value
-            function updateURLParameter(filterObj, urlKey, defaultValue = {}, processFilter = filter => filter) {
-                const parameter = Object.keys(filterObj)
-                    .filter(filter => filterObj[filter])
-                    .sort()
-                    .map(processFilter)
-                    .filter(Boolean) // Filters out any falsy values, including null
-                    .join(',');
-
-                const defaultParameter = Object.keys(defaultValue)
-                    .filter(filter => defaultValue[filter])
-                    .sort()
-                    .map(processFilter)
-                    .filter(Boolean) // Filters out any falsy values, including null
-                    .join(',');
-
-                // if we ended up back at the defaults, just remove it from the URL
-                if (parameter === defaultParameter) {
-                    $location.search(urlKey, null);
-                } else {
-                    $location.search(urlKey, parameter || null);
-                }
-            }
-
-            // updateVersionURLParameter updates the version URL parameter with the given version filters
-            function updateVersionURLParameter(versionFilters) {
-                updateURLParameter(
-                    versionFilters,
-                    'versions', {},
-                    versionFilter => versionFilters[versionFilter].enabled && versionFilters[versionFilter].minorVersion != null
-                        ? `${versionFilterKeyMap[versionFilter]}:${versionFilters[versionFilter].minorVersion}`
-                        : null
-                );
-            }
-
-            // updateAllURLParameters updates all the URL parameters with the current filter settings
-            function updateAllURLParameters() {
-                updateURLParameter($scope.levels, 'levels', LEVEL_FILTERS_DEFAULT);
-                updateURLParameter($scope.groups, 'groups', GROUPS_FILTER_DEFAULT);
-                updateVersionURLParameter($scope.versionFilters);
-                updateURLParameter($scope.applicabilities, 'applicabilities', APPLICABILITIES_FILTER_DEFAULT);
-            }
-
-            // Add $watches to automatically update URL parameters when the data changes
-            $scope.$watch('levels', function (newVal, oldVal) {
-                if (newVal !== oldVal) {
-                    updateURLParameter(newVal, 'levels', LEVEL_FILTERS_DEFAULT);
-                }
-            }, true);
-
-            $scope.$watch('groups', function (newVal, oldVal) {
-                if (newVal !== oldVal) {
-                    updateURLParameter(newVal, 'groups', GROUPS_FILTER_DEFAULT);
-                }
-            }, true);
-
-            $scope.$watch('versionFilters', function (newVal, oldVal) {
-                if (newVal !== oldVal) {
-                    updateVersionURLParameter(newVal);
-                }
-            }, true);
-
-            $scope.$watch('applicabilities', function (newVal, oldVal) {
-                if (newVal !== oldVal) {
-                    updateURLParameter(newVal, 'applicabilities', APPLICABILITIES_FILTER_DEFAULT)
-                }
-            }, true);
-
-            // Watch for changes in the URL path and update the search and lint display
-            $scope.$watch(function () { return $location.path(); }, function (newPath) {
-                const searchParameter = newPath.substring(1);
-                if ($scope.search !== searchParameter) {
-                    $scope.search = searchParameter;
-                    $scope.open[searchParameter] = true;
-                    scrollToLintByURL($scope, $location);
-                }
-            });
-
-            let debounceTimeout;
-            $scope.$watch('search', function (newVal, oldVal) {
-                if (newVal !== oldVal) {
-                    if (debounceTimeout) {
-                        $timeout.cancel(debounceTimeout);
-                    }
-
-                    debounceTimeout = $timeout(function () {
-                        $location.path(newVal);
-                    }, 1000);
-                }
-            });
-
-            $scope.$watch(function () { return $location.search(); }, function (newParameters) {
-                loadFromURLParameters();
-            }, true);
-
-            $scope.updatePath = function () {
-                if (debounceTimeout) {
-                    $timeout.cancel(debounceTimeout);
-                }
-
-                $location.path($scope.search);
-            }
-
-            $scope.toggleLevels = function (value) {
-                const levels = $scope.levels;
-                for (const key in levels) {
-                    if (levels.hasOwnProperty(key)) {
-                        levels[key] = value;
-                    }
-                }
-            };
-
-            $scope.toggleGroups = function (value) {
-                const groups = $scope.groups;
-                for (const key in groups) {
-                    if (groups.hasOwnProperty(key)) {
-                        groups[key] = value;
-                    }
-                }
-            };
-
-            $scope.toggleApplicabilities = function (value) {
-                const applicabilities = $scope.applicabilities;
-                for (const key in applicabilities) {
-                    if (applicabilities.hasOwnProperty(key)) {
-                        applicabilities[key] = value;
-                    }
-                }
-            }
-
-            $scope.resetGroupsToDefault = function () {
-                $scope.groups = {
-                    ...GROUPS_FILTER_DEFAULT
-                };
-            };
-
-            $scope.selectedValuesCount = function (obj) {
-                return Object.values(obj).filter(x => x).length;
-            }
-
-            $scope.clearVersionFilters = function () {
-                for (const filter in $scope.versionFilters) {
-                    $scope.versionFilters[filter] = { enabled: false, minorVersion: null };
-                }
-            }
-
-            $scope.versionFilterCount = function(obj) {
-                return Object.values(obj).filter(x => x.enabled).length;
-            }
-
-            $scope.updateVersionFilters = function() {
-                for (const filter in $scope.versionFilters) {
-                    const minorVersion = $scope.versionFilters[filter].minorVersion;
-
-                    // 1.29.0 and greater
-                    if (minorVersion && minorVersion > 28) {
-                        $scope.versionFilters[filter].enabled = true;
-                        continue;
-                    }
-
-                    $scope.versionFilters[filter].enabled = false;
-                }
-            }
-
-            $scope.byVersion = function(lint) {
-                const filters = $scope.versionFilters;
-                for (const filter in filters) {
-                    if (filters[filter].enabled) {
-                        const minorVersion = filters[filter].minorVersion;
-
-                        // Strip the "pre " prefix for pre 1.29.0 lints
-                        const lintVersion = lint.version.startsWith("pre ") ? lint.version.substring(4, lint.version.length) : lint.version;
-                        const lintMinorVersion = lintVersion.substring(2, 4);
-
-                        switch (filter) {
-                            // "=" gets the highest priority, since all filters are inclusive
-                            case "=":
-                                return (lintMinorVersion == minorVersion);
-                            case "≥":
-                                if (lintMinorVersion < minorVersion) { return false; }
-                                break;
-                            case "≤":
-                                if (lintMinorVersion > minorVersion) { return false; }
-                                break;
-                            default:
-                                return true
-                        }
-                    }
-                }
-
-                return true;
-            }
-
-            $scope.byGroups = function (lint) {
-                return $scope.groups[lint.group];
-            };
-
-            $scope.bySearch = function (lint, index, array) {
-                let searchStr = $scope.search;
-                // It can be `null` I haven't missed this value
-                if (searchStr == null) {
-                    return true;
-                }
-                searchStr = searchStr.toLowerCase();
-                if (searchStr.startsWith("clippy::")) {
-                    searchStr = searchStr.slice(8);
-                }
-
-                // Search by id
-                if (lint.id.indexOf(searchStr.replaceAll("-", "_")) !== -1) {
-                    return true;
-                }
-
-                // Search the description
-                // The use of `for`-loops instead of `foreach` enables us to return early
-                const terms = searchStr.split(" ");
-                const docsLowerCase = lint.docs.toLowerCase();
-                for (index = 0; index < terms.length; index++) {
-                    // This is more likely and will therefore be checked first
-                    if (docsLowerCase.indexOf(terms[index]) !== -1) {
-                        continue;
-                    }
-
-                    if (lint.id.indexOf(terms[index]) !== -1) {
-                        continue;
-                    }
-
-                    return false;
-                }
-
-                return true;
-            }
-
-            $scope.byApplicabilities = function (lint) {
-                return $scope.applicabilities[lint.applicability];
-            };
-
-            // Show details for one lint
-            $scope.openLint = function (lint) {
-                $scope.open[lint.id] = true;
-                $location.path(lint.id);
-            };
-
-            $scope.toggleExpansion = function(lints, isExpanded) {
-                lints.forEach(lint => {
-                    $scope.open[lint.id] = isExpanded;
-                });
-            }
-
-            $scope.copyToClipboard = function (lint) {
-                const clipboard = document.getElementById("clipboard-" + lint.id);
-                if (clipboard) {
-                    let resetClipboardTimeout = null;
-                    const resetClipboardIcon = clipboard.innerHTML;
-
-                    function resetClipboard() {
-                        resetClipboardTimeout = null;
-                        clipboard.innerHTML = resetClipboardIcon;
-                    }
-
-                    navigator.clipboard.writeText("clippy::" + lint.id);
-
-                    clipboard.innerHTML = "&#10003;";
-                    if (resetClipboardTimeout !== null) {
-                        clearTimeout(resetClipboardTimeout);
-                    }
-                    resetClipboardTimeout = setTimeout(resetClipboard, 1000);
-                }
-            }
-
-            // Get data
-            $scope.open = {};
-            $scope.loading = true;
-
-            // This will be used to jump into the source code of the version that this documentation is for.
-            $scope.docVersion = window.location.pathname.split('/')[2] || "master";
-
-            // Set up the filters from the URL parameters before we start loading the data
-            loadFromURLParameters();
-
-            $http.get('./lints.json')
-                .success(function (data) {
-                    $scope.data = data;
-                    $scope.loading = false;
-
-                    const selectedGroup = getQueryVariable("sel");
-                    if (selectedGroup) {
-                        selectGroup($scope, selectedGroup.toLowerCase());
-                    }
-
-                    scrollToLintByURL($scope, $location);
-
-                    setTimeout(function () {
-                        const el = document.getElementById('filter-input');
-                        if (el) { el.focus() }
-                    }, 0);
-                })
-                .error(function (data) {
-                    $scope.error = data;
-                    $scope.loading = false;
-                });
-        });
-})();
-
-function getQueryVariable(variable) {
-    const query = window.location.search.substring(1);
-    const vars = query.split('&');
-    for (const entry of vars) {
-        const pair = entry.split('=');
-        if (decodeURIComponent(pair[0]) == variable) {
-            return decodeURIComponent(pair[1]);
+        if (searchStr.length > 0) {
+            window.location.hash = `/${searchStr}`;
+        } else {
+            window.location.hash = '';
         }
+    },
+};
+
+function handleInputChanged(event) {
+    if (event.target !== document.activeElement) {
+        return;
     }
-}
-
-function storeValue(settingName, value) {
-    try {
-        localStorage.setItem(`clippy-lint-list-${settingName}`, value);
-    } catch (e) { }
-}
-
-function loadValue(settingName) {
-    return localStorage.getItem(`clippy-lint-list-${settingName}`);
-}
-
-function setTheme(theme, store) {
-    let enableHighlight = false;
-    let enableNight = false;
-    let enableAyu = false;
-
-    switch(theme) {
-        case "ayu":
-            enableAyu = true;
-            break;
-        case "coal":
-        case "navy":
-            enableNight = true;
-            break;
-        case "rust":
-            enableHighlight = true;
-            break;
-        default:
-            enableHighlight = true;
-            theme = "light";
-            break;
-    }
-
-    document.getElementsByTagName("body")[0].className = theme;
-
-    document.getElementById("githubLightHighlight").disabled = enableNight || !enableHighlight;
-    document.getElementById("githubDarkHighlight").disabled = !enableNight && !enableAyu;
-
-    document.getElementById("styleHighlight").disabled = !enableHighlight;
-    document.getElementById("styleNight").disabled = !enableNight;
-    document.getElementById("styleAyu").disabled = !enableAyu;
-
-    if (store) {
-        storeValue("theme", theme);
-    } else {
-        document.getElementById(`theme-choice`).value = theme;
-    }
+    searchState.resetInputTimeout();
 }
 
 function handleShortcut(ev) {
@@ -576,8 +102,27 @@ function handleShortcut(ev) {
     }
 }
 
-document.addEventListener("keypress", handleShortcut);
-document.addEventListener("keydown", handleShortcut);
+function toggleElements(filter, value) {
+    let needsUpdate = false;
+    let count = 0;
+
+    const element = document.getElementById(filters[filter].id);
+    onEachLazy(
+        element.querySelectorAll("ul input"),
+        el => {
+            if (el.checked !== value) {
+                el.checked = value;
+                filters[filter][el.getAttribute("data-value")] = value;
+                needsUpdate = true;
+            }
+            count += 1;
+        }
+    );
+    element.querySelector(".badge").innerText = value ? count : 0;
+    if (needsUpdate) {
+        filters.filterLints();
+    }
+}
 
 function changeSetting(elem) {
     if (elem.id === "disable-shortcuts") {
@@ -593,8 +138,52 @@ function onEachLazy(lazyArray, func) {
     }
 }
 
-function handleBlur(event) {
-    const parent = document.getElementById("settings-dropdown");
+function highlightIfNeeded(elem) {
+    onEachLazy(elem.querySelectorAll("pre > code.language-rust:not(.highlighted)"), el => {
+        hljs.highlightElement(el.parentElement)
+        el.classList.add("highlighted");
+    });
+}
+
+function expandLint(lintId) {
+    const lintElem = document.getElementById(lintId);
+    const isCollapsed = lintElem.classList.toggle("collapsed");
+    lintElem.querySelector(".label-doc-folding").innerText = isCollapsed ? "+" : "−";
+    highlightIfNeeded(lintElem);
+}
+
+// Show details for one lint
+function openLint(event) {
+    event.preventDefault();
+    event.stopPropagation();
+    expandLint(event.target.getAttribute("href").slice(1));
+}
+
+function copyToClipboard(event) {
+    event.preventDefault();
+    event.stopPropagation();
+
+    const clipboard = event.target;
+
+    let resetClipboardTimeout = null;
+    const resetClipboardIcon = clipboard.innerHTML;
+
+    function resetClipboard() {
+        resetClipboardTimeout = null;
+        clipboard.innerHTML = resetClipboardIcon;
+    }
+
+    navigator.clipboard.writeText("clippy::" + clipboard.parentElement.id.slice(5));
+
+    clipboard.innerHTML = "&#10003;";
+    if (resetClipboardTimeout !== null) {
+        clearTimeout(resetClipboardTimeout);
+    }
+    resetClipboardTimeout = setTimeout(resetClipboard, 1000);
+}
+
+function handleBlur(event, elementId) {
+    const parent = document.getElementById(elementId);
     if (!parent.contains(document.activeElement) &&
         !parent.contains(event.relatedTarget)
     ) {
@@ -602,28 +191,377 @@ function handleBlur(event) {
     }
 }
 
-function generateSettings() {
-    const settings = document.getElementById("settings-dropdown");
-    const settingsButton = settings.querySelector(".settings-icon")
-    settingsButton.onclick = () => settings.classList.toggle("open");
-    settingsButton.onblur = handleBlur;
-    const settingsMenu = settings.querySelector(".settings-menu");
-    settingsMenu.onblur = handleBlur;
+function toggleExpansion(expand) {
     onEachLazy(
-        settingsMenu.querySelectorAll("input"),
-        el => el.onblur = handleBlur,
+        document.querySelectorAll("article"),
+        expand ? el => {
+            el.classList.remove("collapsed");
+            highlightIfNeeded(el);
+        } : el => el.classList.add("collapsed"),
     );
 }
 
-generateSettings();
-
-// loading the theme after the initial load
-const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
-const theme = loadValue('theme');
-if (prefersDark.matches && !theme) {
-    setTheme("coal", false);
-} else {
-    setTheme(theme, false);
+// Returns the current URL without any query parameter or hash.
+function getNakedUrl() {
+    return window.location.href.split("?")[0].split("#")[0];
 }
+
+const GROUPS_FILTER_DEFAULT = {
+    cargo: true,
+    complexity: true,
+    correctness: true,
+    nursery: true,
+    pedantic: true,
+    perf: true,
+    restriction: true,
+    style: true,
+    suspicious: true,
+    deprecated: false,
+};
+const LEVEL_FILTERS_DEFAULT = {
+    allow: true,
+    warn: true,
+    deny: true,
+    none: true,
+};
+const APPLICABILITIES_FILTER_DEFAULT = {
+    Unspecified: true,
+    MachineApplicable: true,
+    MaybeIncorrect: true,
+    HasPlaceholders: true,
+};
+const URL_PARAMS_CORRESPONDANCE = {
+    "groups_filter": "groups",
+    "levels_filter": "levels",
+    "applicabilities_filter": "applicabilities",
+    "version_filter": "versions",
+};
+const VERSIONS_CORRESPONDANCE = {
+    "lte": "≤",
+    "gte": "≥",
+    "eq": "=",
+};
+
+window.filters = {
+    groups_filter: { id: "lint-groups", ...GROUPS_FILTER_DEFAULT },
+    levels_filter: { id: "lint-levels", ...LEVEL_FILTERS_DEFAULT },
+    applicabilities_filter: { id: "lint-applicabilities", ...APPLICABILITIES_FILTER_DEFAULT },
+    version_filter: {
+        "≥": null,
+        "≤": null,
+        "=": null,
+    },
+    allLints: null,
+    getAllLints: () => {
+        if (filters.allLints === null) {
+            filters.allLints = Array.prototype.slice.call(
+                document.getElementsByTagName("article"),
+            ).map(elem => {
+                let version = elem.querySelector(".label-version").innerText;
+                // Strip the "pre " prefix for pre 1.29.0 lints
+                if (version.startsWith("pre ")) {
+                    version = version.slice(4);
+                }
+                return {
+                    elem: elem,
+                    group: elem.querySelector(".label-lint-group").innerText,
+                    level: elem.querySelector(".label-lint-level").innerText,
+                    version: parseInt(version.split(".")[1]),
+                    applicability: elem.querySelector(".label-applicability").innerText,
+                    filteredOut: false,
+                    searchFilteredOut: false,
+                };
+            });
+        }
+        return filters.allLints;
+    },
+    regenerateURLparams: () => {
+        const urlParams = new URLSearchParams(window.location.search);
+
+        function compareObjects(obj1, obj2) {
+            return (JSON.stringify(obj1) === JSON.stringify({ id: obj1.id, ...obj2 }));
+        }
+        function updateIfNeeded(filterName, obj2) {
+            const obj1 = filters[filterName];
+            const name = URL_PARAMS_CORRESPONDANCE[filterName];
+            if (!compareObjects(obj1, obj2)) {
+                urlParams.set(
+                    name,
+                    Object.entries(obj1).filter(
+                        ([key, value]) => value && key !== "id"
+                    ).map(
+                        ([key, _]) => key
+                    ).join(","),
+                );
+            } else {
+                urlParams.delete(name);
+            }
+        }
+
+        updateIfNeeded("groups_filter", GROUPS_FILTER_DEFAULT);
+        updateIfNeeded("levels_filter", LEVEL_FILTERS_DEFAULT);
+        updateIfNeeded(
+            "applicabilities_filter", APPLICABILITIES_FILTER_DEFAULT);
+
+        const versions = [];
+        if (filters.version_filter["="] !== null) {
+            versions.push(`eq:${filters.version_filter["="]}`);
+        }
+        if (filters.version_filter["≥"] !== null) {
+            versions.push(`gte:${filters.version_filter["≥"]}`);
+        }
+        if (filters.version_filter["≤"] !== null) {
+            versions.push(`lte:${filters.version_filter["≤"]}`);
+        }
+        if (versions.length !== 0) {
+            urlParams.set(URL_PARAMS_CORRESPONDANCE["version_filter"], versions.join(","));
+        } else {
+            urlParams.delete(URL_PARAMS_CORRESPONDANCE["version_filter"]);
+        }
+
+        let params = urlParams.toString();
+        if (params.length !== 0) {
+            params = `?${params}`;
+        }
+
+        const url = getNakedUrl() + params + window.location.hash
+        if (!history.state) {
+            history.pushState(null, "", url);
+        } else {
+            history.replaceState(null, "", url);
+        }
+    },
+    filterLints: () => {
+        // First we regenerate the URL parameters.
+        filters.regenerateURLparams();
+        for (const lint of filters.getAllLints()) {
+            lint.filteredOut = (!filters.groups_filter[lint.group]
+                || !filters.levels_filter[lint.level]
+                || !filters.applicabilities_filter[lint.applicability]
+                || !(filters.version_filter["="] === null || lint.version === filters.version_filter["="])
+                || !(filters.version_filter["≥"] === null || lint.version > filters.version_filter["≥"])
+                || !(filters.version_filter["≤"] === null || lint.version < filters.version_filter["≤"])
+            );
+            if (lint.filteredOut || lint.searchFilteredOut) {
+                lint.elem.style.display = "none";
+            } else {
+                lint.elem.style.display = "";
+            }
+        }
+    },
+};
+
+function updateFilter(elem, filter, skipLintsFiltering) {
+    const value = elem.getAttribute("data-value");
+    if (filters[filter][value] !== elem.checked) {
+        filters[filter][value] = elem.checked;
+        const counter = document.querySelector(`#${filters[filter].id} .badge`);
+        counter.innerText = parseInt(counter.innerText) + (elem.checked ? 1 : -1);
+        if (!skipLintsFiltering) {
+            filters.filterLints();
+        }
+    }
+}
+
+function updateVersionFilters(elem, skipLintsFiltering) {
+    let value = elem.value.trim();
+    if (value.length === 0) {
+        value = null;
+    } else if (/^\d+$/.test(value)) {
+        value = parseInt(value);
+    } else {
+        console.error(`Failed to get version number from "${value}"`);
+        return;
+    }
+
+    const counter = document.querySelector("#version-filter .badge");
+    let count = 0;
+    onEachLazy(document.querySelectorAll("#version-filter input"), el => {
+        if (el.value.trim().length !== 0) {
+            count += 1;
+        }
+    });
+    counter.innerText = count;
+
+    const comparisonKind = elem.getAttribute("data-value");
+    if (filters.version_filter[comparisonKind] !== value) {
+        filters.version_filter[comparisonKind] = value;
+        if (!skipLintsFiltering) {
+            filters.filterLints();
+        }
+    }
+}
+
+function clearVersionFilters() {
+    let needsUpdate = false;
+
+    onEachLazy(document.querySelectorAll("#version-filter input"), el => {
+        el.value = "";
+        const comparisonKind = el.getAttribute("data-value");
+        if (filters.version_filter[comparisonKind] !== null) {
+            needsUpdate = true;
+            filters.version_filter[comparisonKind] = null;
+        }
+    });
+    document.querySelector("#version-filter .badge").innerText = 0;
+    if (needsUpdate) {
+        filters.filterLints();
+    }
+}
+
+function resetGroupsToDefault() {
+    let needsUpdate = false;
+    let count = 0;
+
+    onEachLazy(document.querySelectorAll("#lint-groups-selector input"), el => {
+        const key = el.getAttribute("data-value");
+        const value = GROUPS_FILTER_DEFAULT[key];
+        if (filters.groups_filter[key] !== value) {
+            filters.groups_filter[key] = value;
+            el.checked = value;
+            needsUpdate = true;
+        }
+        if (value) {
+            count += 1;
+        }
+    });
+    document.querySelector("#lint-groups .badge").innerText = count;
+    if (needsUpdate) {
+        filters.filterLints();
+    }
+}
+
+function generateListOfOptions(list, elementId, filter) {
+    let html = '';
+    let nbEnabled = 0;
+    for (const [key, value] of Object.entries(list)) {
+        const attr = value ? " checked" : "";
+        html += `\
+<li class="checkbox">\
+    <label class="text-capitalize">\
+        <input type="checkbox" data-value="${key}" \
+               onchange="updateFilter(this, '${filter}')"${attr}/>${key}\
+    </label>\
+</li>`;
+        if (value) {
+            nbEnabled += 1;
+        }
+    }
+
+    const elem = document.getElementById(`${elementId}-selector`);
+    elem.previousElementSibling.querySelector(".badge").innerText = `${nbEnabled}`;
+    elem.innerHTML += html;
+
+    setupDropdown(elementId);
+}
+
+function setupDropdown(elementId) {
+    const elem = document.getElementById(elementId);
+    const button = document.querySelector(`#${elementId} > button`);
+    button.onclick = () => elem.classList.toggle("open");
+
+    const setBlur = child => {
+        child.onblur = event => handleBlur(event, elementId);
+    };
+    onEachLazy(elem.children, setBlur);
+    onEachLazy(elem.querySelectorAll("select"), setBlur);
+    onEachLazy(elem.querySelectorAll("input"), setBlur);
+    onEachLazy(elem.querySelectorAll("ul button"), setBlur);
+}
+
+function generateSettings() {
+    setupDropdown("settings-dropdown");
+
+    generateListOfOptions(LEVEL_FILTERS_DEFAULT, "lint-levels", "levels_filter");
+    generateListOfOptions(GROUPS_FILTER_DEFAULT, "lint-groups", "groups_filter");
+    generateListOfOptions(
+        APPLICABILITIES_FILTER_DEFAULT, "lint-applicabilities", "applicabilities_filter");
+
+    let html = '';
+    for (const kind of ["≥", "≤", "="]) {
+        html += `\
+<li class="checkbox">\
+    <label>${kind}</label>\
+    <span>1.</span> \
+    <input type="number" \
+           min="29" \
+           class="version-filter-input form-control filter-input" \
+           maxlength="2" \
+           data-value="${kind}" \
+           onchange="updateVersionFilters(this)" \
+           oninput="updateVersionFilters(this)" \
+           onkeydown="updateVersionFilters(this)" \
+           onkeyup="updateVersionFilters(this)" \
+           onpaste="updateVersionFilters(this)" \
+    />
+    <span>.0</span>\
+</li>`;
+    }
+    document.getElementById("version-filter-selector").innerHTML += html;
+    setupDropdown("version-filter");
+}
+
+function generateSearch() {
+    searchState.inputElem.addEventListener("change", handleInputChanged);
+    searchState.inputElem.addEventListener("input", handleInputChanged);
+    searchState.inputElem.addEventListener("keydown", handleInputChanged);
+    searchState.inputElem.addEventListener("keyup", handleInputChanged);
+    searchState.inputElem.addEventListener("paste", handleInputChanged);
+}
+
+function scrollToLint(lintId) {
+    const target = document.getElementById(lintId);
+    if (!target) {
+        return;
+    }
+    target.scrollIntoView();
+    expandLint(lintId);
+}
+
+// If the page we arrive on has link to a given lint, we scroll to it.
+function scrollToLintByURL() {
+    const lintId = window.location.hash.substring(2);
+    if (lintId.length > 0) {
+        scrollToLint(lintId);
+    }
+}
+
+function parseURLFilters() {
+    const urlParams = new URLSearchParams(window.location.search);
+
+    for (const [key, value] of urlParams.entries()) {
+        for (const [corres_key, corres_value] of Object.entries(URL_PARAMS_CORRESPONDANCE)) {
+            if (corres_value === key) {
+                if (key !== "versions") {
+                    const settings  = new Set(value.split(","));
+                    onEachLazy(document.querySelectorAll(`#lint-${key} ul input`), elem => {
+                        elem.checked = settings.has(elem.getAttribute("data-value"));
+                        updateFilter(elem, corres_key, true);
+                    });
+                } else {
+                    const settings = value.split(",").map(elem => elem.split(":"));
+
+                    for (const [kind, value] of settings) {
+                        const elem = document.querySelector(
+                            `#version-filter input[data-value="${VERSIONS_CORRESPONDANCE[kind]}"]`);
+                        elem.value = value;
+                        updateVersionFilters(elem, true);
+                    }
+                }
+            }
+        }
+    }
+}
+
+document.getElementById(`theme-choice`).value = loadValue("theme");
 let disableShortcuts = loadValue('disable-shortcuts') === "true";
 document.getElementById("disable-shortcuts").checked = disableShortcuts;
+
+document.addEventListener("keypress", handleShortcut);
+document.addEventListener("keydown", handleShortcut);
+
+generateSettings();
+generateSearch();
+parseURLFilters();
+scrollToLintByURL();
+filters.filterLints();
diff --git a/util/gh-pages/style.css b/util/gh-pages/style.css
index a9485d51104..a68a10b1401 100644
--- a/util/gh-pages/style.css
+++ b/util/gh-pages/style.css
@@ -272,8 +272,9 @@ L4.75,12h2.5l0.5393066-2.1572876  c0.2276001-0.1062012,0.4459839-0.2269287,0.649
   height: 18px;
   display: block;
   filter: invert(0.7);
-  padding-left: 4px;
-  padding-top: 3px;
+  position: absolute;
+  top: 4px;
+  left: 5px;
 }
 
 .settings-menu * {
@@ -329,6 +330,18 @@ L4.75,12h2.5l0.5393066-2.1572876  c0.2276001-0.1062012,0.4459839-0.2269287,0.649
     display: flex;
 }
 
+ul.dropdown-menu li.checkbox > button {
+    border: 0;
+    width: 100%;
+    background: var(--theme-popup-bg);
+    color: var(--fg);
+}
+
+ul.dropdown-menu li.checkbox > button:hover {
+    background: var(--theme-hover);
+    box-shadow: none;
+}
+
 #version-filter {
     min-width: available;
 }
@@ -396,3 +409,37 @@ body {
     background: var(--bg);
     color: var(--fg);
 }
+
+article.collapsed .lint-docs {
+    display: none;
+}
+
+.github-corner svg {
+    fill: var(--fg);
+    color: var(--bg);
+}
+.github-corner:hover .octo-arm {
+    animation: octocat-wave 560ms ease-in-out;
+}
+@keyframes octocat-wave {
+    0%,
+    100% {
+        transform: rotate(0);
+    }
+    20%,
+    60% {
+        transform: rotate(-25deg);
+    }
+    40%,
+    80% {
+        transform: rotate(10deg);
+    }
+}
+@media (max-width: 500px) {
+    .github-corner:hover .octo-arm {
+        animation: none;
+    }
+    .github-corner .octo-arm {
+        animation: octocat-wave 560ms ease-in-out;
+    }
+}
diff --git a/util/gh-pages/theme.js b/util/gh-pages/theme.js
new file mode 100644
index 00000000000..bc296955ddf
--- /dev/null
+++ b/util/gh-pages/theme.js
@@ -0,0 +1,56 @@
+function storeValue(settingName, value) {
+    try {
+        localStorage.setItem(`clippy-lint-list-${settingName}`, value);
+    } catch (e) { }
+}
+
+function loadValue(settingName) {
+    return localStorage.getItem(`clippy-lint-list-${settingName}`);
+}
+
+function setTheme(theme, store) {
+    let enableHighlight = false;
+    let enableNight = false;
+    let enableAyu = false;
+
+    switch(theme) {
+        case "ayu":
+            enableAyu = true;
+            break;
+        case "coal":
+        case "navy":
+            enableNight = true;
+            break;
+        case "rust":
+            enableHighlight = true;
+            break;
+        default:
+            enableHighlight = true;
+            theme = "light";
+            break;
+    }
+
+    document.body.className = theme;
+
+    document.getElementById("githubLightHighlight").disabled = enableNight || !enableHighlight;
+    document.getElementById("githubDarkHighlight").disabled = !enableNight && !enableAyu;
+
+    document.getElementById("styleHighlight").disabled = !enableHighlight;
+    document.getElementById("styleNight").disabled = !enableNight;
+    document.getElementById("styleAyu").disabled = !enableAyu;
+
+    if (store) {
+        storeValue("theme", theme);
+    }
+}
+
+(function() {
+    // loading the theme after the initial load
+    const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
+    const theme = loadValue("theme");
+    if (prefersDark.matches && !theme) {
+        setTheme("coal", false);
+    } else {
+        setTheme(theme, false);
+    }
+})();