- ,
- linter: Option<&Linter>,
default_rules: &RuleTable,
) {
- if linter.is_some() {
- table_out.push_str("| Code { scope='col' } ");
- }
- table_out.push_str(
- "| Name { scope='col' } | Message { scope='col' } | \
- \
- Status/Fix/Default\
- { scope='col' } |",
+ let table_start = table_out.len();
+ table_out.push('\n');
+ let default_categories = Category::default_categories().iter().join(" ");
+ let _ = writeln!(
+ table_out,
+ "| Code {{ scope='col' .rule-code }} \
+ | Rule {{ scope='col' .rule-identity }} \
+ | Category {{ scope='col' .rule-category data-default-categories='{default_categories}' }} \
+ | Linter {{ scope='col' .rule-linter }} \
+ | Status {{ scope='col' .rule-status aria-label='Status, fix availability, and default selection' }} |"
);
+ table_out.push_str("| ---- | ---- | -------- | ------ | -: |");
table_out.push('\n');
- if linter.is_some() {
- table_out.push_str("| ---- ");
- }
- table_out.push_str("| ---- | ------- | -: |");
- table_out.push('\n');
+ let mut seen_anchors = HashSet::new();
+ let mut linters = BTreeMap::new();
for rule in rules {
- let status_token = match rule.status() {
+ let status = rule.status();
+ let status_token = match status {
RuleStatus::Removed { since } => {
format!(
"{REMOVED_SYMBOL}Rule was removed in {since}"
@@ -75,13 +73,15 @@ fn generate_table(
}
};
- let fix_token = match rule.fixable() {
- FixAvailability::Always | FixAvailability::Sometimes => {
- format!(
- "{FIX_SYMBOL}Automatic fix available"
- )
- }
- FixAvailability::None => format!(""),
+ let fix_token = if matches!(
+ rule.fixable(),
+ FixAvailability::Always | FixAvailability::Sometimes
+ ) {
+ format!(
+ "{FIX_SYMBOL}Automatic fix available"
+ )
+ } else {
+ format!("")
};
let default_token = if default_rules.enabled(rule) {
@@ -94,15 +94,7 @@ fn generate_table(
let rule_name = rule.name();
- // If the message ends in a bracketed expression (like: "Use {replacement}"), escape the
- // brackets. Otherwise, it'll be interpreted as an HTML attribute via the `attr_list`
- // plugin. (Above, we'd convert to "Use {replacement\}".)
let message = rule.message_formats()[0];
- let message = if let Some(prefix) = message.strip_suffix('}') {
- Cow::Owned(format!("{prefix}\\}}"))
- } else {
- Cow::Borrowed(message)
- };
// Start and end of style spans
let mut ss = "";
@@ -115,35 +107,114 @@ fn generate_table(
se = "";
}
- if let Some(linter) = linter {
- let _ = write!(
- table_out,
- "| {ss}{prefix}{code}{se} {{ #{prefix}{code} }} ",
- prefix = linter.common_prefix(),
- code = linter.code_for_rule(rule).unwrap(),
+ let code = rule.noqa_code().map(|code| code.to_string());
+ let linter = code
+ .as_deref()
+ .and_then(|code| Linter::parse_code(code).map(|(linter, _)| linter));
+ let (linter_name, linter_slug, linter_label) = if let Some(linter) = &linter {
+ let prefixes = match linter.common_prefix() {
+ "" => linter
+ .upstream_categories()
+ .into_iter()
+ .flatten()
+ .map(|category| category.prefix)
+ .join(", "),
+ prefix => prefix.to_string(),
+ };
+ // The linter names for Ruff and NumPy are suffixed with "-specific rules."
+ let name = linter.name().trim_end_matches("-specific rules");
+ let slug = format!(
+ "{}-{}",
+ linter.name().to_lowercase().replace(' ', "-"),
+ prefixes.to_lowercase().replace(", ", "-"),
);
+ (name, slug, format!("{name} ({prefixes})"))
+ } else {
+ (
+ "—",
+ "rules-without-codes".to_string(),
+ "No originating linter".to_string(),
+ )
+ };
+ // Preserve links to the old linter headings on their first table row.
+ let linter_anchor = if seen_anchors.insert(linter_slug.clone()) {
+ linters.insert(
+ linter_label.to_lowercase(),
+ (linter_slug.clone(), linter_label.clone()),
+ );
+ format!("#{linter_slug}")
+ } else {
+ String::new()
+ };
+
+ if let Some(code) = code {
+ table_out.push_str("| ");
+ // The former subgroups have separate anchors in addition to each rule's code.
+ if let Some(linter) = linter
+ && let Some(category) = rule.upstream_category(&linter)
+ {
+ let slug = format!(
+ "{}-{}{}",
+ category.category.to_lowercase().replace(' ', "-"),
+ linter.common_prefix().to_lowercase(),
+ category.prefix.to_lowercase(),
+ );
+ if seen_anchors.insert(slug.clone()) {
+ let _ = write!(table_out, "");
+ }
+ }
+ let _ = write!(table_out, "{ss}`{code}`{se} {{ #{code} .rule-code }} ");
+ } else {
+ let _ = write!(table_out, "| {ss}—{se} {{ .rule-code }} ");
}
+ // Message placeholders can be mistaken for an attribute list on the table cell.
+ // Keep the identity class on an HTML wrapper so messages retain their Markdown.
#[expect(clippy::or_fun_call)]
let _ = write!(
table_out,
- "| {ss}{explanation}{se} | {ss}{message}{se} |
{status_token}{fix_token}{default_token}
|",
+ "| {ss}{explanation}
{message}{se} \
+ | {ss}{category}{se} {{ .rule-category }} \
+ | {ss}{linter_name}{se} {{ {linter_anchor} .rule-linter data-linter='{linter_slug}' }} \
+ | {status_token}{fix_token}{default_token}
{{ .rule-status data-status='{status}' }} |",
+ category = rule.category(),
explanation = rule
.explanation()
.is_some()
- .then_some(format_args!("[{rule_name}](rules/{rule_name}.md)"))
- .unwrap_or(format_args!("{rule_name}")),
+ .then_some(format_args!("[`{rule_name}`](rules/{rule_name}.md)"))
+ .unwrap_or(format_args!("`{rule_name}`")),
);
table_out.push('\n');
}
table_out.push('\n');
+
+ // Reuse the table's linter labels and slugs for the filter options.
+ let category_options = Category::iter()
+ .map(|category| {
+ format!("")
+ })
+ .join("\n");
+ let linter_options = linters
+ .values()
+ .map(|(value, label)| {
+ format!("")
+ })
+ .join("\n");
+ table_out.insert_str(
+ table_start,
+ &format!(
+ include_str!("../../../docs/.overrides/partials/rule-filters.html"),
+ category_options = category_options,
+ linter_options = linter_options,
+ ),
+ );
}
pub(crate) fn generate() -> String {
// Generate the table string.
let mut table_out = String::new();
- table_out.push_str("### Legend");
+ table_out.push_str("\nRule legend
\n\n### Legend");
table_out.push('\n');
let _ = write!(
@@ -176,114 +247,15 @@ pub(crate) fn generate() -> String {
);
table_out.push_str("\n\n");
table_out.push_str("All rules not marked as preview, deprecated or removed are stable.");
- table_out.push('\n');
+ table_out.push_str("\n\n \n\n");
- let default_rules = LinterSettings::default().rules;
- for linter in Linter::iter() {
- let codes_csv: String = match linter.common_prefix() {
- "" => linter
- .upstream_categories()
- .unwrap()
- .iter()
- .map(|c| c.prefix)
- .join(", "),
- prefix => prefix.to_string(),
- };
- let _ = write!(&mut table_out, "### {} ({codes_csv})", linter.name());
- table_out.push('\n');
- table_out.push('\n');
-
- if let Some(url) = linter.url() {
- let host = url
- .trim_start_matches("https://")
- .split('/')
- .next()
- .unwrap();
- let _ = write!(
- table_out,
- "For more, see [{}]({}) on {}.",
- linter.name(),
- url,
- match host {
- "pypi.org" => "PyPI",
- "github.com" => "GitHub",
- host => panic!(
- "unexpected host in URL of {}, expected pypi.org or github.com but found \
- {host}",
- linter.name()
- ),
- }
- );
- table_out.push('\n');
- table_out.push('\n');
- }
-
- // The linter names for Ruff and NumPy are suffixed with "-specific rules."
- let linter_name = linter.name().trim_end_matches("-specific rules");
- // Several linter names are capitalized, but their settings are not.
- let linter_name_lower = linter_name.to_lowercase();
- if Options::metadata().has(&format!("lint.{linter_name_lower}")) {
- let _ = write!(
- table_out,
- "For related settings, see [{linter_name}](settings.md#lint{linter_name_lower}).",
- );
- table_out.push('\n');
- table_out.push('\n');
- }
-
- let rules_by_upstream_category = linter
- .all_rules()
- .map(|rule| (rule.upstream_category(&linter), rule))
- .into_group_map();
-
- let mut rules_by_upstream_category: Vec<_> = rules_by_upstream_category.iter().collect();
-
- // Sort the upstream categories alphabetically by prefix.
- rules_by_upstream_category.sort_by(|(a, _), (b, _)| {
- a.as_ref()
- .map(|category| category.prefix)
- .unwrap_or_default()
- .cmp(
- b.as_ref()
- .map(|category| category.prefix)
- .unwrap_or_default(),
- )
- });
-
- if rules_by_upstream_category.len() > 1 {
- for (opt, rules) in rules_by_upstream_category {
- if opt.is_some() {
- let UpstreamCategoryAndPrefix { category, prefix } = opt.unwrap();
- match codes_csv.as_str() {
- "PL" => {
- let _ = write!(table_out, "#### {category} ({codes_csv}{prefix})");
- }
- _ => {
- let _ = write!(table_out, "#### {category} ({prefix})");
- }
- }
- }
- table_out.push('\n');
- table_out.push('\n');
- generate_table(&mut table_out, rules.clone(), Some(&linter), &default_rules);
- }
- } else {
- generate_table(
- &mut table_out,
- linter.all_rules(),
- Some(&linter),
- &default_rules,
- );
- }
- }
-
- let mut codeless_rules = Rule::iter()
- .filter(|rule| rule.noqa_code().is_none())
- .peekable();
- if codeless_rules.peek().is_some() {
- table_out.push_str("### Rules without codes\n\n");
- generate_table(&mut table_out, codeless_rules, None, &default_rules);
- }
+ generate_table(
+ &mut table_out,
+ Linter::iter()
+ .flat_map(|linter| linter.all_rules())
+ .chain(Rule::iter().filter(|rule| rule.noqa_code().is_none())),
+ &LinterSettings::default().rules,
+ );
table_out
}
diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs
index 18fcdea9b4..fa9a3461a3 100644
--- a/crates/ruff_linter/src/codes.rs
+++ b/crates/ruff_linter/src/codes.rs
@@ -109,9 +109,9 @@ impl serde::Serialize for NoqaCode {
/// ALL < category < linter group < linter prefix < rule
/// ```
///
-/// The ordering of variants isn't currently used anywhere, but they should be kept in descending
-/// order of severity, with error categories first, followed by warning, and then by off-by-default
-/// categories.
+/// Variants are ordered by descending severity, with error categories first, followed by warning,
+/// and then by off-by-default categories. The rule documentation lists category filter options
+/// in this order.
///
/// See our [rule categorization guidelines] for more information on assigning categories.
///
@@ -223,6 +223,17 @@ pub enum RuleStatus {
Removed { since: &'static str },
}
+impl std::fmt::Display for RuleStatus {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ f.write_str(match self {
+ RuleStatus::Stable { .. } => "stable",
+ RuleStatus::Preview { .. } => "preview",
+ RuleStatus::Deprecated { .. } => "deprecated",
+ RuleStatus::Removed { .. } => "removed",
+ })
+ }
+}
+
#[ruff_macros::map_codes]
pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleStatus, Rule)> {
#[expect(clippy::enum_glob_use)]
diff --git a/docs/.overrides/partials/rule-filters.html b/docs/.overrides/partials/rule-filters.html
new file mode 100644
index 0000000000..deb35afb70
--- /dev/null
+++ b/docs/.overrides/partials/rule-filters.html
@@ -0,0 +1,59 @@
+
diff --git a/docs/js/rule-filters.js b/docs/js/rule-filters.js
new file mode 100644
index 0000000000..e245293f87
--- /dev/null
+++ b/docs/js/rule-filters.js
@@ -0,0 +1,299 @@
+(() => {
+ let restoreFilters = () => {};
+
+ document$.subscribe(() => {
+ restoreFilters = () => {};
+
+ const form = document.getElementById("rule-filters");
+ if (!form || !("popover" in HTMLElement.prototype)) {
+ return;
+ }
+
+ const article = form.closest("article");
+ const table = article.querySelector(".rule-category")?.closest("table");
+ if (!table) {
+ return;
+ }
+
+ table.id = "rule-catalog";
+ const categoryMetadata =
+ table.tHead.querySelector(".rule-category").dataset;
+ const body = table.tBodies[0];
+
+ const rows = Array.from(body.rows, (element) => {
+ const [code, identity, category, linter, status] = element.cells;
+ return {
+ element,
+ text: `${code.textContent} ${identity.querySelector("code").textContent}`.toLowerCase(),
+ category: category.textContent.trim(),
+ linter: linter.dataset.linter,
+ status: status.dataset.status,
+ };
+ });
+
+ const search = form.elements.namedItem("rule-search");
+ const count = form.querySelector(".rule-filters__count");
+ const empty = form.querySelector(".rule-filters__empty");
+
+ const filters = [...form.querySelectorAll("[data-filter]")].map((group) => {
+ const name = group.dataset.filter;
+ const trigger = group.querySelector("[popovertarget]");
+ const panel = group.querySelector("[popover]");
+ const selection = trigger.firstElementChild;
+ const options = [...panel.querySelectorAll("input")];
+ const allValues = options.map((option) => option.value);
+
+ const isAll = () => options.every((option) => option.checked);
+
+ const updateSummary = () => {
+ const labels = options
+ .filter((option) => option.checked)
+ .map((option) => option.parentElement.textContent.trim());
+
+ selection.textContent =
+ labels.length === options.length
+ ? "All"
+ : labels.length === 0
+ ? "None"
+ : labels.length === 1
+ ? labels[0]
+ : `${labels.length} selected`;
+ trigger.title =
+ isAll() || !labels.length ? selection.textContent : labels.join(", ");
+ trigger.setAttribute(
+ "aria-label",
+ `${group.closest("fieldset").querySelector("legend").textContent}: ${selection.textContent}`,
+ );
+ };
+
+ const setValues = (selected = allValues) => {
+ for (const option of options) {
+ option.checked = selected.includes(option.value);
+ }
+ };
+
+ const presets = {
+ all: allValues,
+ default: categoryMetadata.defaultCategories.split(" "),
+ none: [],
+ };
+
+ for (const button of panel.querySelectorAll("[data-preset]")) {
+ button.addEventListener("click", () => {
+ setValues(presets[button.dataset.preset]);
+ applyFilters();
+ });
+ }
+
+ const controls = [...panel.querySelectorAll("button, input")];
+ group.addEventListener("keydown", (event) => {
+ if (
+ !panel.matches(":popover-open") ||
+ (event.key !== "ArrowDown" && event.key !== "ArrowUp") ||
+ event.altKey ||
+ event.ctrlKey ||
+ event.metaKey
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ const direction = event.key === "ArrowDown" ? 1 : -1;
+ const index = controls.indexOf(event.target);
+ const nextIndex =
+ index === -1
+ ? direction === 1
+ ? 0
+ : controls.length - 1
+ : Math.max(0, Math.min(controls.length - 1, index + direction));
+ controls[nextIndex]?.focus();
+ });
+
+ group.addEventListener("focusout", (event) => {
+ if (
+ panel.matches(":popover-open") &&
+ event.relatedTarget &&
+ !group.contains(event.relatedTarget)
+ ) {
+ panel.hidePopover();
+ }
+ });
+
+ return {
+ name,
+ isAll,
+ setValues,
+ updateSummary,
+ };
+ });
+
+ function clearFilters() {
+ search.value = "";
+ for (const filter of filters) {
+ filter.setValues();
+ }
+ }
+
+ const container = table.closest(".md-typeset__scrollwrap") || table;
+ container.classList.add("rule-catalog-scroll");
+ container.setAttribute("role", "region");
+ container.setAttribute("aria-label", "Rules");
+ container.tabIndex = 0;
+ form.hidden = false;
+
+ for (const toc of document.querySelectorAll(".md-nav--secondary")) {
+ toc.hidden = true;
+ toc.parentElement.classList.add("rule-index-nav");
+ }
+
+ function filter() {
+ for (const filter of filters) {
+ filter.updateSummary();
+ }
+
+ const formData = new FormData(form);
+ const categories = formData.getAll("category");
+ const selectedLinters = formData.getAll("linter");
+ const statuses = formData.getAll("status");
+ const terms = search.value
+ .trim()
+ .toLowerCase()
+ .split(/\s+/)
+ .filter(Boolean);
+
+ let visible = 0;
+ for (const row of rows) {
+ const matches =
+ categories.includes(row.category) &&
+ selectedLinters.includes(row.linter) &&
+ statuses.includes(row.status) &&
+ terms.every((term) => row.text.includes(term));
+
+ row.element.hidden = !matches;
+ if (matches) {
+ visible++;
+ }
+ }
+
+ count.textContent =
+ visible === rows.length
+ ? `${visible} rules`
+ : `${visible} of ${rows.length} rules`;
+ empty.hidden = visible !== 0;
+ container.hidden = visible === 0;
+ }
+
+ const fragmentLinks = [...document.querySelectorAll("a[href]")].filter(
+ (link) =>
+ link.hash &&
+ link.origin === location.origin &&
+ link.pathname === location.pathname,
+ );
+
+ function updateFragmentLinks() {
+ // Material resolves fragment links before filters can change the query.
+ for (const link of fragmentLinks) {
+ link.search = location.search;
+ }
+ }
+
+ function updateURL(clearFragment = false) {
+ const formData = new FormData(form);
+ const url = new URL(location.href);
+ if (clearFragment) {
+ url.hash = "";
+ }
+
+ if (search.value) {
+ url.searchParams.set(search.name, search.value);
+ } else {
+ url.searchParams.delete(search.name);
+ }
+
+ for (const filter of filters) {
+ url.searchParams.delete(filter.name);
+ if (filter.isAll()) {
+ continue;
+ }
+
+ const selected = formData.getAll(filter.name);
+ // An omitted parameter means All; preserve an explicit empty selection.
+ if (!selected.length) {
+ url.searchParams.set(filter.name, "none");
+ }
+
+ for (const value of selected) {
+ url.searchParams.append(filter.name, value);
+ }
+ }
+
+ history.replaceState(history.state, "", url);
+ updateFragmentLinks();
+ }
+
+ restoreFilters = () => {
+ if (!form.isConnected) {
+ return;
+ }
+
+ const url = new URL(location.href);
+ search.value = url.searchParams.get(search.name) || "";
+ for (const filter of filters) {
+ filter.setValues(
+ url.searchParams.has(filter.name)
+ ? url.searchParams.getAll(filter.name)
+ : undefined,
+ );
+ }
+
+ let fragment;
+ try {
+ fragment = decodeURIComponent(url.hash.slice(1));
+ } catch {
+ fragment = "";
+ }
+
+ filter();
+ updateFragmentLinks();
+
+ const target = fragment && document.getElementById(fragment);
+ if (!target || !article.contains(target)) {
+ return;
+ }
+
+ const details = target.closest("details");
+ if (details) {
+ details.open = true;
+ }
+
+ if (target.closest("[hidden]")) {
+ clearFilters();
+ filter();
+ updateURL();
+ target.scrollIntoView();
+ }
+ };
+
+ function applyFilters() {
+ filter();
+ updateURL(true);
+ }
+
+ form.addEventListener("input", applyFilters);
+
+ form.addEventListener("submit", (event) => {
+ event.preventDefault();
+ applyFilters();
+ });
+
+ form.addEventListener("reset", (event) => {
+ event.preventDefault();
+ clearFilters();
+ applyFilters();
+ });
+
+ restoreFilters();
+ });
+
+ location$.subscribe(() => restoreFilters());
+})();
diff --git a/docs/stylesheets/rule-filters.css b/docs/stylesheets/rule-filters.css
new file mode 100644
index 0000000000..ce2198fe47
--- /dev/null
+++ b/docs/stylesheets/rule-filters.css
@@ -0,0 +1,194 @@
+.md-typeset .rule-filters {
+ margin: 1em 0;
+ padding: 0.75rem 0;
+ border-bottom: 1px solid var(--md-default-fg-color--lightest);
+}
+
+.rule-filters__controls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 0.75rem;
+}
+
+.rule-filters__search,
+.rule-filters fieldset {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ gap: 0.3rem;
+ font-size: 0.7rem;
+ font-weight: 600;
+}
+
+.rule-filters fieldset {
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.rule-filters legend {
+ margin-bottom: 0.3rem;
+}
+
+.rule-filters [popovertarget] {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.4rem;
+ width: 100%;
+ anchor-name: var(--rule-filter-anchor);
+}
+
+.rule-filters [popovertarget] span:first-child {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.rule-filters__options {
+ width: 14rem;
+ max-width: calc(100vw - 2rem);
+ max-height: min(16rem, calc(50dvh - 2rem));
+ padding: 0.3rem;
+ overflow-y: auto;
+ border: 1px solid var(--md-default-fg-color--lightest);
+ border-radius: 0.2rem;
+ background: var(--md-default-bg-color);
+ color: var(--md-default-fg-color);
+ box-shadow: 0 0.2rem 0.5rem #0002;
+ position-anchor: var(--rule-filter-anchor);
+}
+
+/* Browsers without anchor positioning keep the popover centered. */
+@supports (top: anchor(bottom)) {
+ .rule-filters__options {
+ inset: auto;
+ top: anchor(bottom);
+ left: anchor(left);
+ width: anchor-size(width);
+ margin: 0.25rem 0 0;
+ position-try-fallbacks: flip-block;
+ }
+}
+
+.rule-filters__options label {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.35rem;
+ font-weight: 400;
+ overflow-wrap: anywhere;
+ cursor: pointer;
+}
+
+.rule-filters__options label:hover,
+.rule-filters__actions button:hover {
+ background: var(--md-code-bg-color);
+}
+
+.rule-filters__options input {
+ flex: none;
+ width: 0.8rem;
+ height: 0.8rem;
+ accent-color: var(--md-typeset-a-color);
+}
+
+.rule-filters__actions {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 0.3rem;
+ padding-bottom: 0.3rem;
+ border-bottom: 1px solid var(--md-default-fg-color--lightest);
+}
+
+.rule-filters .rule-filters__actions button {
+ width: 100%;
+ border: 0;
+ border-radius: 0;
+ text-align: center;
+}
+
+.rule-filters__search {
+ flex: 2 1 12rem;
+}
+
+.rule-filters__category {
+ --rule-filter-anchor: --rule-category;
+ flex: 1 1 8rem;
+}
+
+.rule-filters__linter {
+ --rule-filter-anchor: --rule-linter;
+ flex: 1 1 10rem;
+}
+
+.rule-filters__status {
+ --rule-filter-anchor: --rule-status;
+ flex: 1 1 7rem;
+}
+
+.rule-filters input[type="search"],
+.rule-filters button {
+ min-width: 0;
+ min-height: 2.1rem;
+ padding: 0.4rem 0.6rem;
+ border: 1px solid var(--md-default-fg-color--lightest);
+ border-radius: 0.2rem;
+ background: var(--md-default-bg-color);
+ color: var(--md-default-fg-color);
+ font: inherit;
+ font-weight: 400;
+}
+
+.rule-filters input::placeholder {
+ color: var(--md-default-fg-color--light);
+}
+
+.rule-filters button {
+ font-size: 0.7rem;
+ cursor: pointer;
+}
+
+.rule-filters button:hover {
+ border-color: var(--md-typeset-a-color);
+ color: var(--md-typeset-a-color);
+}
+
+.rule-filters :is(input, button):focus-visible,
+.rule-catalog-scroll:focus-visible {
+ outline: 2px solid var(--md-typeset-a-color);
+ outline-offset: 2px;
+}
+
+.rule-filters__actions button:focus-visible {
+ outline-offset: -2px;
+}
+
+.md-typeset .rule-filters__count {
+ margin: 0.5rem 0 0;
+ color: var(--md-default-fg-color--light);
+ font-size: 0.65rem;
+}
+
+#rule-catalog [hidden],
+.rule-catalog-scroll[hidden],
+.rule-filters [hidden],
+.rule-index-nav > label[for="__toc"],
+.md-nav--secondary[hidden] {
+ display: none !important;
+}
+
+.rule-index-nav > a.md-nav__link {
+ display: flex !important;
+}
+
+@media (max-width: 59.999em) {
+ .rule-filters__search {
+ flex-basis: 100%;
+ }
+
+ .rule-filters__linter {
+ flex-basis: 8rem;
+ }
+}
diff --git a/mkdocs.template.yml b/mkdocs.template.yml
index c60cf74a52..5b78cfbeac 100644
--- a/mkdocs.template.yml
+++ b/mkdocs.template.yml
@@ -104,8 +104,10 @@ plugins:
- faq.md
extra_css:
- stylesheets/extra.css
+ - stylesheets/rule-filters.css
extra_javascript:
- js/extra.js
+ - js/rule-filters.js
not_in_nav: |
/rules/*
/formatter/*