mirror of
https://github.com/astral-sh/ruff.git
synced 2026-10-28 10:45:37 +08:00
Update rules table with category information (#28651)
Summary -- This PR updates our rules page with a big table inspired by [oxlint] and [clippy]. I'm afraid this might be a bit of a pain to review, as Codex added a ton of JS and CSS to achieve our desired functionality. I iterated with it a few times trying to shrink the diff but without much luck. Supersedes https://github.com/astral-sh/ruff/pull/28099 [clippy]: https://rust-lang.github.io/rust-clippy/master/ [oxlint]: https://oxc.rs/docs/guide/usage/linter/rules.html Test Plan -- Mostly local testing: https://github.com/user-attachments/assets/fbf70dd0-1e43-467f-8d35-2767203aab3f
This commit is contained in:
1 parent
68073852bf
commit
67b3bd4122
6 files changed
+693
-156
No files matched your search
@@ -2,19 +2,17 @@
|
||||
//!
|
||||
//! Used for <https://docs.astral.sh/ruff/rules/>.
|
||||
|
||||
use itertools::Itertools;
|
||||
use ruff_linter::codes::RuleStatus;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::fmt::Write;
|
||||
|
||||
use itertools::Itertools;
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
use ruff_linter::FixAvailability;
|
||||
use ruff_linter::codes::{Category, RuleStatus};
|
||||
use ruff_linter::registry::{Linter, Rule, RuleNamespace};
|
||||
use ruff_linter::settings::LinterSettings;
|
||||
use ruff_linter::settings::rule_table::RuleTable;
|
||||
use ruff_linter::upstream_categories::UpstreamCategoryAndPrefix;
|
||||
use ruff_options_metadata::OptionsMetadata;
|
||||
use ruff_workspace::options::Options;
|
||||
|
||||
const DEFAULT_SYMBOL: &str = "✅";
|
||||
const FIX_SYMBOL: &str = "🛠️";
|
||||
@@ -33,26 +31,26 @@ const SYMBOLS_CONTAINER: &str =
|
||||
fn generate_table(
|
||||
table_out: &mut String,
|
||||
rules: impl IntoIterator<Item = Rule>,
|
||||
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' } | \
|
||||
<span style='position: relative;'>\
|
||||
<span class='sr-only'>Status/Fix/Default</span>\
|
||||
</span> { 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!(
|
||||
"<span aria-hidden='true' {SYMBOL_STYLE} title='Rule was removed in {since}'>{REMOVED_SYMBOL}</span><span class='sr-only'>Rule was removed in {since}</span>"
|
||||
@@ -75,13 +73,15 @@ fn generate_table(
|
||||
}
|
||||
};
|
||||
|
||||
let fix_token = match rule.fixable() {
|
||||
FixAvailability::Always | FixAvailability::Sometimes => {
|
||||
format!(
|
||||
"<span aria-hidden='true' {SYMBOL_STYLE} title='Automatic fix available'>{FIX_SYMBOL}</span><span class='sr-only'>Automatic fix available</span>"
|
||||
)
|
||||
}
|
||||
FixAvailability::None => format!("<span {SYMBOL_STYLE}></span>"),
|
||||
let fix_token = if matches!(
|
||||
rule.fixable(),
|
||||
FixAvailability::Always | FixAvailability::Sometimes
|
||||
) {
|
||||
format!(
|
||||
"<span aria-hidden='true' {SYMBOL_STYLE} title='Automatic fix available'>{FIX_SYMBOL}</span><span class='sr-only'>Automatic fix available</span>"
|
||||
)
|
||||
} else {
|
||||
format!("<span {SYMBOL_STYLE}></span>")
|
||||
};
|
||||
|
||||
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 = "</span>";
|
||||
}
|
||||
|
||||
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, "<span id='{slug}'></span>");
|
||||
}
|
||||
}
|
||||
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} | <div {SYMBOLS_CONTAINER}>{status_token}{fix_token}{default_token}</div>|",
|
||||
"| <span class='rule-identity'>{ss}{explanation}<br>{message}{se}</span> \
|
||||
| {ss}{category}{se} {{ .rule-category }} \
|
||||
| {ss}{linter_name}{se} {{ {linter_anchor} .rule-linter data-linter='{linter_slug}' }} \
|
||||
| <div {SYMBOLS_CONTAINER}>{status_token}{fix_token}{default_token}</div> {{ .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!("<label><input type='checkbox' name='category' value='{category}' checked> {category}</label>")
|
||||
})
|
||||
.join("\n");
|
||||
let linter_options = linters
|
||||
.values()
|
||||
.map(|(value, label)| {
|
||||
format!("<label><input type='checkbox' name='linter' value='{value}' checked> {label}</label>")
|
||||
})
|
||||
.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("<details markdown=\"1\">\n<summary>Rule legend</summary>\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</details>\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
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<form id="rule-filters" class="rule-filters" role="search" aria-label="Filter rules" hidden>
|
||||
<div class="rule-filters__controls">
|
||||
<label class="rule-filters__search">
|
||||
Search rules
|
||||
<input type="search" name="rule-search" placeholder="Code or name"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</label>
|
||||
|
||||
<fieldset class="rule-filters__category">
|
||||
<legend>Category</legend>
|
||||
<div data-filter="category">
|
||||
<button type="button" popovertarget="rule-category-options"><span>All</span><span aria-hidden="true">▾</span></button>
|
||||
<div id="rule-category-options" class="rule-filters__options" popover>
|
||||
<div class="rule-filters__actions">
|
||||
<button type="button" data-preset="all">All</button>
|
||||
<button type="button" data-preset="default">Default</button>
|
||||
<button type="button" data-preset="none">None</button>
|
||||
</div>
|
||||
{category_options}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="rule-filters__linter">
|
||||
<legend>Linter</legend>
|
||||
<div data-filter="linter">
|
||||
<button type="button" popovertarget="rule-linter-options"><span>All</span><span aria-hidden="true">▾</span></button>
|
||||
<div id="rule-linter-options" class="rule-filters__options" popover>
|
||||
<div class="rule-filters__actions">
|
||||
<button type="button" data-preset="all">All</button>
|
||||
<button type="button" data-preset="none">None</button>
|
||||
</div>
|
||||
{linter_options}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="rule-filters__status">
|
||||
<legend>Status</legend>
|
||||
<div data-filter="status">
|
||||
<button type="button" popovertarget="rule-status-options"><span>All</span><span aria-hidden="true">▾</span></button>
|
||||
<div id="rule-status-options" class="rule-filters__options" popover>
|
||||
<div class="rule-filters__actions">
|
||||
<button type="button" data-preset="all">All</button>
|
||||
<button type="button" data-preset="none">None</button>
|
||||
</div>
|
||||
<label><input type="checkbox" name="status" value="stable" checked> Stable</label>
|
||||
<label><input type="checkbox" name="status" value="preview" checked> Preview</label>
|
||||
<label><input type="checkbox" name="status" value="deprecated" checked> Deprecated</label>
|
||||
<label><input type="checkbox" name="status" value="removed" checked> Removed</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<button type="reset">Clear filters</button>
|
||||
</div>
|
||||
<p class="rule-filters__count" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
<p class="rule-filters__empty" hidden>No matching rules. Try another search or clear the filters.</p>
|
||||
</form>
|
||||
@@ -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());
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/*
|
||||
|
||||
Reference in new issue
Block a user