feat(website): auto-generate rule docs pages (#4640)

> AI-generated description because I'm lazy
### TL;DR

This PR introduces the ability to generate documentation for linter rules and adds new methods and metadata for rule fix capabilities.

To see what this looks like, please check out https://github.com/oxc-project/oxc-project.github.io/pull/165.

## Screenshots
Hyperlinks to rule doc pages in auto-generated rules table
<img width="809" alt="image" src="https://github.com/user-attachments/assets/e09eb47d-e86a-4ed1-b1f9-5034f33c71a2">

Example of a docs page
<img width="1273" alt="image" src="https://github.com/user-attachments/assets/78f7e9e6-f4dd-4cc9-aebc-1cdd64b024ec">

### What changed?

- Added `RuleFixMeta` to indicate rule fix capabilities
- Introduced methods `is_none` and `is_pending` in `RuleFixMeta`
- Modified `render_markdown_table` in `RuleTableSection` to accept an optional link prefix
- Created new modules for rule documentation and HTML rendering
- Updated `print_rules` function to generate markdown for rules and detailed documentation pages

### How to test?

Run the `linter-rules` task with appropriate arguments to generate the markdown table and documentation pages.
Verify the generated files for correctness and that all metadata is correctly displayed.

### Why make this change?

To enhance the project documentation and provide clear rule fix capabilities, thereby improving the developer experience and easing the integration process.

---
This commit is contained in:
DonIsaac 2024-08-10 00:13:06 +00:00
parent d191823a0a
commit f62951411d
16 changed files with 377 additions and 57 deletions

View file

@ -31,7 +31,7 @@ pub use crate::{
fixer::FixKind,
frameworks::FrameworkFlags,
options::{AllowWarnDeny, LintOptions},
rule::{RuleCategory, RuleMeta, RuleWithSeverity},
rule::{RuleCategory, RuleFixMeta, RuleMeta, RuleWithSeverity},
service::{LintService, LintServiceOptions},
};
use crate::{
@ -146,7 +146,7 @@ impl Linter {
pub fn print_rules<W: Write>(writer: &mut W) {
let table = RuleTable::new();
for section in table.sections {
writeln!(writer, "{}", section.render_markdown_table()).unwrap();
writeln!(writer, "{}", section.render_markdown_table(None)).unwrap();
}
writeln!(writer, "Default: {}", table.turned_on_by_default_count).unwrap();
writeln!(writer, "Total: {}", table.total).unwrap();

View file

@ -117,7 +117,7 @@ impl fmt::Display for RuleCategory {
// NOTE: this could be packed into a single byte if we wanted. I don't think
// this is needed, but we could do it if it would have a performance impact.
/// Describes the auto-fixing capabilities of a [`Rule`].
/// Describes the auto-fixing capabilities of a `Rule`.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RuleFixMeta {
/// An auto-fix is not available.
@ -132,7 +132,12 @@ pub enum RuleFixMeta {
}
impl RuleFixMeta {
/// Does this [`Rule`] have some kind of auto-fix available?
#[inline]
pub fn is_none(self) -> bool {
matches!(self, Self::None)
}
/// Does this `Rule` have some kind of auto-fix available?
///
/// Also returns `true` for suggestions.
#[inline]
@ -140,6 +145,11 @@ impl RuleFixMeta {
matches!(self, Self::Fixable(_) | Self::Conditional(_))
}
#[inline]
pub fn is_pending(self) -> bool {
matches!(self, Self::FixPending)
}
pub fn supports_fix(self, kind: FixKind) -> bool {
matches!(self, Self::Fixable(fix_kind) | Self::Conditional(fix_kind) if fix_kind.can_apply(kind))
}
@ -163,9 +173,10 @@ impl RuleFixMeta {
let mut message =
if kind.is_dangerous() { format!("dangerous {noun}") } else { noun.into() };
let article = match message.chars().next().unwrap() {
'a' | 'e' | 'i' | 'o' | 'u' => "An",
_ => "A",
let article = match message.chars().next() {
Some('a' | 'e' | 'i' | 'o' | 'u') => "An",
Some(_) => "A",
None => unreachable!(),
};
if matches!(self, Self::Conditional(_)) {

View file

@ -40,10 +40,12 @@ pub struct Namespace {
declare_oxc_lint!(
/// ### What it does
/// Enforces names exist at the time they are dereferenced, when imported as a full namespace (i.e. import * as foo from './foo'; foo.bar(); will report if bar is not exported by ./foo.).
/// Will report at the import declaration if there are no exported names found.
/// Also, will report for computed references (i.e. foo["bar"]()).
/// Reports on assignment to a member of an imported namespace.
/// Enforces names exist at the time they are dereferenced, when imported as
/// a full namespace (i.e. `import * as foo from './foo'; foo.bar();` will
/// report if bar is not exported by `./foo.`). Will report at the import
/// declaration if there are no exported names found. Also, will report for
/// computed references (i.e. `foo["bar"]()`). Reports on assignment to a
/// member of an imported namespace.
Namespace,
correctness
);

View file

@ -56,7 +56,7 @@ declare_oxc_lint!(
///
/// Consider the following:
///
/// ```javascript
/// ```jsx
/// <a href="javascript:void(0)" onClick={foo}>Perform action</a>
/// <a href="#" onClick={foo}>Perform action</a>
/// <a onClick={foo}>Perform action</a>
@ -64,7 +64,7 @@ declare_oxc_lint!(
///
/// All these anchor implementations indicate that the element is only used to execute JavaScript code. All the above should be replaced with:
///
/// ```javascript
/// ```jsx
/// <button onClick={foo}>Perform action</button>
/// ```
/// `
@ -78,33 +78,19 @@ declare_oxc_lint!(
///
/// #### Valid
///
/// ```javascript
/// ```jsx
/// <a href={`https://www.javascript.com`}>navigate here</a>
/// ```
///
/// ```javascript
/// <a href={somewhere}>navigate here</a>
/// ```
///
/// ```javascript
/// <a {...spread}>navigate here</a>
/// ```
///
/// #### Invalid
///
/// ```javascript
/// ```jsx
/// <a href={null}>navigate here</a>
/// ```
/// ```javascript
/// <a href={undefined}>navigate here</a>
/// ```
/// ```javascript
/// <a href>navigate here</a>
/// ```
/// ```javascript
/// <a href="javascript:void(0)">navigate here</a>
/// ```
/// ```javascript
/// <a href="https://example.com" onClick={something}>navigate here</a>
/// ```
///

View file

@ -26,7 +26,7 @@ pub struct Lang;
declare_oxc_lint!(
/// ### What it does
///
/// The lang prop on the <html> element must be a valid IETF's BCP 47 language tag.
/// The lang prop on the `<html>` element must be a valid IETF's BCP 47 language tag.
///
/// ### Why is this bad?
///
@ -39,13 +39,13 @@ declare_oxc_lint!(
/// ### Example
///
/// // good
/// ```javascript
/// ```jsx
/// <html lang="en">
/// <html lang="en-US">
/// ```
///
/// // bad
/// ```javascript
/// ```jsx
/// <html>
/// <html lang="foo">
/// ````

View file

@ -22,15 +22,17 @@ declare_oxc_lint!(
///
/// ### Why is this necessary?
///
/// Elements that can be visually distracting can cause accessibility issues with visually impaired users.
/// Such elements are most likely deprecated, and should be avoided. By default, <marquee> and <blink> elements are visually distracting.
/// Elements that can be visually distracting can cause accessibility issues
/// with visually impaired users. Such elements are most likely deprecated,
/// and should be avoided. By default, `<marquee>` and `<blink>` elements
/// are visually distracting.
///
/// ### What it checks
///
/// This rule checks for marquee and blink element.
///
/// ### Example
/// ```javascript
/// ```jsx
/// // Bad
/// <marquee />
/// <marquee {...props} />

View file

@ -23,7 +23,7 @@ pub struct Scope;
declare_oxc_lint!(
/// ### What it does
///
/// The scope prop should be used only on <th> elements.
/// The scope prop should be used only on `<th>` elements.
///
/// ### Why is this bad?
/// The scope attribute makes table navigation much easier for screen reader users, provided that it is used correctly.
@ -31,7 +31,7 @@ declare_oxc_lint!(
/// A screen reader operates under the assumption that a table has a header and that this header specifies a scope. Because of the way screen readers function, having an accurate header makes viewing a table far more accessible and more efficient for people who use the device.
///
/// ### Example
/// ```javascript
/// ```jsx
/// // Bad
/// <div scope />
///

View file

@ -10,13 +10,13 @@ pub struct NoDuplicateHead;
declare_oxc_lint!(
/// ### What it does
/// Prevent duplicate usage of <Head> in pages/_document.js.
/// Prevent duplicate usage of `<Head>` in `pages/_document.js``.
///
/// ### Why is this bad?
/// This can cause unexpected behavior in your application.
///
/// ### Example
/// ```javascript
/// ```jsx
/// import Document, { Html, Head, Main, NextScript } from 'next/document'
/// class MyDocument extends Document {
/// static async getInitialProps(ctx) {

View file

@ -59,12 +59,14 @@ declare_oxc_lint!(
///
/// ### Why is this bad?
///
/// Generic type parameters (<T>) in TypeScript may be "constrained" with an extends keyword.
/// When no extends is provided, type parameters default a constraint to unknown. It is therefore redundant to extend from any or unknown.
/// Generic type parameters (`<T>`) in TypeScript may be "constrained" with
/// an extends keyword. When no extends is provided, type parameters
/// default a constraint to unknown. It is therefore redundant to extend
/// from any or unknown.
///
/// the rule doesn't allow const {allowedName} = this
/// the rule doesn't allow `const {allowedName} = this`
/// this is to keep 1:1 with eslint implementation
/// sampe with obj.<allowedName> = this
/// sampe with `obj.<allowedName> = this`
/// ```
NoThisAlias,
correctness

View file

@ -28,11 +28,11 @@ declare_oxc_lint!(
///
/// ### Why is this bad?
///
/// Generic type parameters (<T>) in TypeScript may be "constrained" with an extends keyword.
/// Generic type parameters (`<T>`) in TypeScript may be "constrained" with an extends keyword.
/// When no extends is provided, type parameters default a constraint to unknown. It is therefore redundant to extend from any or unknown.
///
/// ### Example
/// ```javascript
/// ```typescript
/// interface FooAny<T extends any> {}
/// interface FooUnknown<T extends unknown> {}
/// type BarAny<T extends any> = {};

View file

@ -1,8 +1,8 @@
use std::fmt::Write;
use std::{borrow::Cow, fmt::Write};
use rustc_hash::{FxHashMap, FxHashSet};
use crate::{rules::RULES, Linter, RuleCategory};
use crate::{rules::RULES, Linter, RuleCategory, RuleFixMeta};
pub struct RuleTable {
pub sections: Vec<RuleTableSection>,
@ -23,6 +23,7 @@ pub struct RuleTableRow {
pub category: RuleCategory,
pub documentation: Option<&'static str>,
pub turned_on_by_default: bool,
pub autofix: RuleFixMeta,
}
impl Default for RuleTable {
@ -49,6 +50,7 @@ impl RuleTable {
plugin: rule.plugin_name().to_string(),
category: rule.category(),
turned_on_by_default: default_rules.contains(name),
autofix: rule.fix(),
}
})
.collect::<Vec<_>>();
@ -88,7 +90,11 @@ impl RuleTable {
}
impl RuleTableSection {
pub fn render_markdown_table(&self) -> String {
/// Renders all the rules in this section as a markdown table.
///
/// Provide [`Some`] prefix to render the rule name as a link. Provide
/// [`None`] to just display the rule name as text.
pub fn render_markdown_table(&self, link_prefix: Option<&str>) -> String {
let mut s = String::new();
let category = &self.category;
let rows = &self.rows;
@ -108,7 +114,12 @@ impl RuleTableSection {
let plugin_name = &row.plugin;
let (default, default_width) =
if row.turned_on_by_default { ("", 6) } else { ("", 7) };
writeln!(s, "| {rule_name:<rule_width$} | {plugin_name:<plugin_width$} | {default:<default_width$} |").unwrap();
let rendered_name = if let Some(prefix) = link_prefix {
Cow::Owned(format!("[{rule_name}]({prefix}/{plugin_name}/{rule_name}.html)"))
} else {
Cow::Borrowed(rule_name)
};
writeln!(s, "| {rendered_name:<rule_width$} | {plugin_name:<plugin_width$} | {default:<default_width$} |").unwrap();
}
s

View file

@ -0,0 +1,57 @@
//! Create documentation pages for each rule. Pages are printed as Markdown and
//! get added to the website.
use oxc_linter::{table::RuleTableRow, RuleFixMeta};
use std::fmt::{self, Write};
use crate::linter::rules::html::HtmlWriter;
pub fn render_rule_docs_page(rule: &RuleTableRow) -> Result<String, fmt::Error> {
const APPROX_FIX_CATEGORY_AND_PLUGIN_LEN: usize = 512;
let RuleTableRow { name, documentation, plugin, turned_on_by_default, autofix, .. } = rule;
let mut page = HtmlWriter::with_capacity(
documentation.map_or(0, str::len) + name.len() + APPROX_FIX_CATEGORY_AND_PLUGIN_LEN,
);
writeln!(
page,
"<!-- This file is auto-generated by {}. Do not edit it manually. -->\n",
file!()
)?;
writeln!(page, "# {plugin}/{name}\n")?;
// rule metadata
page.div(r#"class="rule-meta""#, |p| {
if *turned_on_by_default {
p.span(r#"class="default-on""#, |p| {
p.writeln("✅ This rule is turned on by default.")
})?;
}
if let Some(emoji) = fix_emoji(*autofix) {
p.span(r#"class="fix""#, |p| {
p.writeln(format!("{} {}", emoji, autofix.description()))
})?;
}
Ok(())
})?;
// rule documentation
if let Some(docs) = documentation {
writeln!(page, "\n{}", *docs)?;
}
// TODO: link to rule source
Ok(page.into())
}
fn fix_emoji(fix: RuleFixMeta) -> Option<&'static str> {
match fix {
RuleFixMeta::None => None,
RuleFixMeta::FixPending => Some("🚧"),
RuleFixMeta::Conditional(_) | RuleFixMeta::Fixable(_) => Some("🛠️"),
}
}

View file

@ -0,0 +1,121 @@
use std::{
cell::RefCell,
fmt::{self, Write},
};
#[derive(Debug)]
pub(crate) struct HtmlWriter {
inner: RefCell<String>,
}
impl fmt::Write for HtmlWriter {
#[inline]
fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result {
self.inner.get_mut().write_fmt(args)
}
#[inline]
fn write_char(&mut self, c: char) -> fmt::Result {
self.inner.get_mut().write_char(c)
}
#[inline]
fn write_str(&mut self, s: &str) -> fmt::Result {
self.inner.get_mut().write_str(s)
}
}
impl From<HtmlWriter> for String {
#[inline]
fn from(html: HtmlWriter) -> Self {
html.into_inner()
}
}
impl HtmlWriter {
pub fn new() -> Self {
Self { inner: RefCell::new(String::new()) }
}
pub fn with_capacity(capacity: usize) -> Self {
Self { inner: RefCell::new(String::with_capacity(capacity)) }
}
pub fn writeln<S: AsRef<str>>(&self, line: S) -> fmt::Result {
writeln!(self.inner.borrow_mut(), "{}", line.as_ref())
}
pub fn into_inner(self) -> String {
self.inner.into_inner()
}
pub fn html<F>(&self, tag: &'static str, attrs: &str, inner: F) -> fmt::Result
where
F: FnOnce(&Self) -> fmt::Result,
{
// Allocate space for the HTML being printed
let write_amt_guess = {
// opening tag. 2 extra for '<' and '>'
2 + tag.len() + attrs.len() +
// approximate inner content length
256 +
// closing tag. 3 extra for '</' and '>'
3 + tag.len()
};
let mut s = self.inner.borrow_mut();
s.reserve(write_amt_guess);
// Write the opening tag
write!(s, "<{tag}")?;
if attrs.is_empty() {
writeln!(s, ">")?;
} else {
writeln!(s, " {attrs}>")?;
}
// Callback produces the inner content
drop(s);
inner(self)?;
// Write the closing tag
writeln!(self.inner.borrow_mut(), "</{tag}>")?;
Ok(())
}
}
macro_rules! make_tag {
($name:ident) => {
impl HtmlWriter {
#[inline]
pub fn $name<F>(&self, attrs: &str, inner: F) -> fmt::Result
where
F: FnOnce(&Self) -> fmt::Result,
{
self.html(stringify!($name), attrs, inner)
}
}
};
}
make_tag!(div);
make_tag!(span);
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_div() {
let html = HtmlWriter::new();
html.div("", |html| html.writeln("Hello, world!")).unwrap();
assert_eq!(
html.into_inner().as_str(),
"<div>
Hello, world!
</div>
"
);
}
}

View file

@ -0,0 +1,128 @@
mod doc_page;
mod html;
mod table;
use std::{
borrow::Cow,
env, fs,
path::{Path, PathBuf},
process,
};
use doc_page::render_rule_docs_page;
use oxc_linter::table::RuleTable;
use pico_args::Arguments;
use table::render_rules_table;
const HELP: &str = "
usage: linter-rules [args]
Arguments:
-t,--table <path> Path to file where rule markdown table will be saved.
-r,--rule-docs <path> Path to directory where rule doc pages will be saved.
A directory will be created if one doesn't exist.
-h,--help Show this help message.
";
/// `cargo run -p website linter-rules --table
/// /path/to/oxc/oxc-project.github.io/src/docs/guide/usage/linter/generated-rules.md
/// --rule-docs /path/to/oxc/oxc-project.github.io/src/docs/guide/usage/linter/rules
/// `
/// <https://oxc.rs/docs/guide/usage/linter/rules.html>
pub fn print_rules(mut args: Arguments) {
let pwd = PathBuf::from(env::var("PWD").unwrap());
if args.contains(["-h", "--help"]) {
println!("{HELP}");
return;
}
let table = RuleTable::new();
let table_path = args.opt_value_from_str::<_, PathBuf>(["-t", "--table"]).unwrap();
let rules_dir = args.opt_value_from_str::<_, PathBuf>(["-r", "--rule-docs"]).unwrap();
let (prefix, root) = rules_dir.as_ref().and_then(|p| p.as_os_str().to_str()).map_or(
(Cow::Borrowed(""), None),
|p| {
if p.contains("src/docs") {
let split = p.split("src/docs").collect::<Vec<_>>();
assert!(split.len() > 1);
let root = split[0];
let root = pwd.join(root).canonicalize().unwrap();
let prefix = Cow::Owned("/docs".to_string() + split.last().unwrap());
(prefix, Some(root))
} else {
(Cow::Borrowed(p), None)
}
},
);
if let Some(table_path) = table_path {
let table_path = pwd.join(table_path).canonicalize().unwrap();
println!("Rendering rules table...");
let rules_table = render_rules_table(&table, prefix.as_ref());
fs::write(table_path, rules_table).unwrap();
}
if let Some(rules_dir) = rules_dir {
println!("Rendering rule doc pages...");
let rules_dir = pwd.join(rules_dir);
if !rules_dir.exists() {
fs::create_dir_all(&rules_dir).unwrap();
}
let rules_dir = rules_dir.canonicalize().unwrap();
assert!(
!rules_dir.is_file(),
"Cannot write rule docs to a file. Please specify a directory."
);
write_rule_doc_pages(&table, &rules_dir);
// auto-fix code and natural language issues
if let Some(root) = root {
println!("Formatting rule doc pages...");
prettier(&root, &rules_dir);
println!("Fixing textlint issues...");
textlint(&root);
}
}
println!("Done.");
}
fn write_rule_doc_pages(table: &RuleTable, outdir: &Path) {
for rule in table.sections.iter().flat_map(|section| &section.rows) {
let plugin_path = outdir.join(&rule.plugin);
fs::create_dir_all(&plugin_path).unwrap();
let page_path = plugin_path.join(format!("{}.md", rule.name));
println!("{}", page_path.display());
let docs = render_rule_docs_page(rule).unwrap();
fs::write(&page_path, docs).unwrap();
}
}
/// Run prettier and fix style issues in generated rule doc pages.
fn prettier(website_root: &Path, rule_docs_path: &Path) {
assert!(rule_docs_path.is_dir(), "Rule docs path must be a directory.");
assert!(rule_docs_path.is_absolute(), "Rule docs path must be an absolute path.");
let relative_path = rule_docs_path.strip_prefix(website_root).unwrap();
let path_str =
relative_path.to_str().expect("Invalid rule docs path: could not convert to str");
let generated_md_glob = format!("{path_str}/**/*.md");
process::Command::new("pnpm")
.current_dir(website_root)
.args(["run", "fmt", "--write", &generated_md_glob])
.status()
.unwrap();
}
/// Run textlint and fix any issues it finds.
fn textlint(website_root: &Path) {
assert!(website_root.is_dir(), "Rule docs path must be a directory.");
process::Command::new("pnpm")
.current_dir(website_root)
.args(["run", "textlint:fix"])
.status()
.unwrap();
}

View file

@ -2,20 +2,20 @@ use oxc_linter::table::RuleTable;
// `cargo run -p website linter-rules > /path/to/oxc/oxc-project.github.io/src/docs/guide/usage/linter/generated-rules.md`
// <https://oxc.rs/docs/guide/usage/linter/rules.html>
pub fn print_rules() {
let table = RuleTable::new();
/// `docs_prefix` is a path prefix to the base URL all rule documentation pages
/// share in common.
pub fn render_rules_table(table: &RuleTable, docs_prefix: &str) -> String {
let total = table.total;
let turned_on_by_default_count = table.turned_on_by_default_count;
let body = table
.sections
.into_iter()
.map(|section| section.render_markdown_table())
.iter()
.map(|s| s.render_markdown_table(Some(docs_prefix)))
.collect::<Vec<_>>()
.join("\n");
println!("
format!("
# Rules
The progress of all rule implementations is tracked [here](https://github.com/oxc-project/oxc/issues/481).
@ -29,5 +29,5 @@ The progress of all rule implementations is tracked [here](https://github.com/ox
<!-- textlint-enable -->
");
")
}

View file

@ -12,7 +12,7 @@ fn main() {
"linter-schema-json" => linter::print_schema_json(),
"linter-schema-markdown" => linter::print_schema_markdown(),
"linter-cli" => linter::print_cli(),
"linter-rules" => linter::print_rules(),
"linter-rules" => linter::print_rules(args),
_ => println!("Missing task command."),
}
}