diff --git a/NEWS.md b/NEWS.md index dd4059795..8b6e7bda6 100644 --- a/NEWS.md +++ b/NEWS.md @@ -7,6 +7,7 @@ Unreleased * Introduce `suspenders:advisories` generator * Introduce `suspenders:styles` generator * Introduce `suspenders:jobs` generator +* Introduce `suspenders:lint` generator 20230113.0 (January, 13, 2023) diff --git a/README.md b/README.md index 5c01735ee..da472fde3 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,26 @@ improvement for the viewer. [inline_svg]: https://github.com/jamesmartin/inline_svg +### Lint + +Creates a holistic linting solution that covers JavaScript, CSS, Ruby and ERB. + +Introduces NPM commands that leverage [@thoughtbot/eslint-config][], +[@thoughtbot/stylelint-config][] and [prettier][]. + +Also introduces `.prettierrc` based off of our [Guides][]. + +Introduces `rake standard` which also runs `erblint` to lint ERB files +via [better_html][], [erb_lint][] and [erblint-github][]. + +[@thoughtbot/eslint-config]: https://github.com/thoughtbot/eslint-config +[@thoughtbot/stylelint-config]: https://github.com/thoughtbot/stylelint-config +[prettier]: https://prettier.io +[Guides]: https://github.com/thoughtbot/guides/blob/main/javascript/README.md#formatting +[better_html]: https://github.com/Shopify/better-html +[erb_lint]: https://github.com/Shopify/erb-lint +[erblint-github]: https://github.com/github/erblint-github + ### Styles Configures applications to use [PostCSS][] or [Tailwind][] via diff --git a/lib/generators/suspenders/lint_generator.rb b/lib/generators/suspenders/lint_generator.rb new file mode 100644 index 000000000..599168343 --- /dev/null +++ b/lib/generators/suspenders/lint_generator.rb @@ -0,0 +1,77 @@ +module Suspenders + module Generators + class LintGenerator < Rails::Generators::Base + include Suspenders::Generators::Helpers + + source_root File.expand_path("../../templates/lint", __FILE__) + desc "Creates a holistic linting solution that covers JavaScript, CSS, Ruby and ERB." + + def install_dependencies + run "yarn add stylelint@^15.10.1 eslint @thoughtbot/stylelint-config@3.0.0 @thoughtbot/eslint-config npm-run-all prettier --dev" + end + + def install_gems + gem_group :development, :test do + gem "better_html", require: false + gem "erb_lint", require: false + gem "erblint-github", require: false + gem "standard" + end + Bundler.with_unbundled_env { run "bundle install" } + end + + def configure_stylelint + copy_file "stylelintrc.json", ".stylelintrc.json" + end + + def configure_eslint + copy_file "eslintrc.json", ".eslintrc.json" + end + + def configure_prettier + copy_file "prettierrc", ".prettierrc" + end + + def configure_erb_lint + copy_file "erb-lint.yml", ".erb-lint.yml" + copy_file "config_better_html.yml", "config/better_html.yml" + copy_file "config_initializers_better_html.rb", "config/initializers/better_html.rb" + copy_file "erblint.rake", "lib/tasks/erblint.rake" + template "rubocop.yml.tt", ".rubocop.yml" + + if default_test_suite? + copy_file "better_html_test.rb", "test/views/better_html_test.rb" + elsif rspec_test_suite? + copy_file "better_html_spec.rb", "spec/views/better_html_spec.rb" + end + end + + def update_package_json + content = File.read package_json + json = JSON.parse content + json["scripts"] ||= {} + + json["scripts"]["lint"] = "run-p lint:eslint lint:stylelint lint:prettier" + json["scripts"]["lint:eslint"] = "eslint --max-warnings=0 --no-error-on-unmatched-pattern 'app/javascript/**/*.js'" + json["scripts"]["lint:stylelint"] = "stylelint 'app/assets/stylesheets/**/*.css'" + json["scripts"]["lint:prettier"] = "prettier --check '**/*' --ignore-unknown" + json["scripts"]["fix:prettier"] = "prettier --write '**/*' --ignore-unknown" + + File.write package_json, JSON.pretty_generate(json) + end + + # This needs to be the last method definition to ensure everything is + # properly configured + def fix_violations + run "yarn run fix:prettier" + run "bundle exec rake standard:fix_unsafely" + end + + private + + def package_json + Rails.root.join("package.json") + end + end + end +end diff --git a/lib/generators/templates/lint/better_html_spec.rb b/lib/generators/templates/lint/better_html_spec.rb new file mode 100644 index 000000000..d567c66ea --- /dev/null +++ b/lib/generators/templates/lint/better_html_spec.rb @@ -0,0 +1,17 @@ +require "spec_helper" + +describe "ERB Implementation" do + def self.erb_lint + configuration = ActiveSupport::ConfigurationFile.parse(".erb-lint.yml") + + ActiveSupport::OrderedOptions.new.merge!(configuration.deep_symbolize_keys!) + end + + Rails.root.glob(erb_lint.glob).each do |template| + it "raises no html errors in #{template.relative_path_from(Rails.root)}" do + validator = BetterHtml::BetterErb::ErubiImplementation.new(template.read) + + validator.validate! + end + end +end diff --git a/lib/generators/templates/lint/better_html_test.rb b/lib/generators/templates/lint/better_html_test.rb new file mode 100644 index 000000000..9138c0ee5 --- /dev/null +++ b/lib/generators/templates/lint/better_html_test.rb @@ -0,0 +1,17 @@ +require "test_helper" + +class ErbImplementationTest < ActiveSupport::TestCase + def self.erb_lint + configuration = ActiveSupport::ConfigurationFile.parse(".erb-lint.yml") + + ActiveSupport::OrderedOptions.new.merge!(configuration.deep_symbolize_keys!) + end + + Rails.root.glob(erb_lint.glob).each do |template| + test "html errors in #{template.relative_path_from(Rails.root)}" do + validator = BetterHtml::BetterErb::ErubiImplementation.new(template.read) + + validator.validate! + end + end +end diff --git a/lib/generators/templates/lint/config_better_html.yml b/lib/generators/templates/lint/config_better_html.yml new file mode 100644 index 000000000..eb6826cff --- /dev/null +++ b/lib/generators/templates/lint/config_better_html.yml @@ -0,0 +1,2 @@ +--- +allow_single_quoted_attributes: false diff --git a/lib/generators/templates/lint/config_initializers_better_html.rb b/lib/generators/templates/lint/config_initializers_better_html.rb new file mode 100644 index 000000000..b117fade8 --- /dev/null +++ b/lib/generators/templates/lint/config_initializers_better_html.rb @@ -0,0 +1,9 @@ +Rails.configuration.to_prepare do + if Rails.env.test? + require "better_html" + + BetterHtml.config = BetterHtml::Config.new(Rails.configuration.x.better_html) + + BetterHtml.config.template_exclusion_filter = proc { |filename| !filename.start_with?(Rails.root.to_s) } + end +end diff --git a/lib/generators/templates/lint/erb-lint.yml b/lib/generators/templates/lint/erb-lint.yml new file mode 100644 index 000000000..aac222c15 --- /dev/null +++ b/lib/generators/templates/lint/erb-lint.yml @@ -0,0 +1,63 @@ +--- +glob: "app/views/**/*.{html,turbo_stream}{+*,}.erb" + +linters: + AllowedScriptType: + enabled: true + allowed_types: + - "module" + - "text/javascript" + ErbSafety: + enabled: true + better_html_config: "config/better_html.yml" + GitHub::Accessibility::AvoidBothDisabledAndAriaDisabledCounter: + enabled: true + GitHub::Accessibility::AvoidGenericLinkTextCounter: + enabled: true + GitHub::Accessibility::DisabledAttributeCounter: + enabled: true + GitHub::Accessibility::IframeHasTitleCounter: + enabled: true + GitHub::Accessibility::ImageHasAltCounter: + enabled: true + GitHub::Accessibility::LandmarkHasLabelCounter: + enabled: true + GitHub::Accessibility::LinkHasHrefCounter: + enabled: true + GitHub::Accessibility::NestedInteractiveElementsCounter: + enabled: true + GitHub::Accessibility::NoAriaLabelMisuseCounter: + enabled: true + GitHub::Accessibility::NoPositiveTabIndexCounter: + enabled: true + GitHub::Accessibility::NoRedundantImageAltCounter: + enabled: true + GitHub::Accessibility::NoTitleAttributeCounter: + enabled: true + GitHub::Accessibility::SvgHasAccessibleTextCounter: + enabled: true + Rubocop: + enabled: true + rubocop_config: + inherit_from: + - .rubocop.yml + + Lint/EmptyBlock: + Enabled: false + Layout/InitialIndentation: + Enabled: false + Layout/TrailingEmptyLines: + Enabled: false + Layout/TrailingWhitespace: + Enabled: false + Layout/LeadingEmptyLines: + Enabled: false + Style/FrozenStringLiteralComment: + Enabled: false + Style/MultilineTernaryOperator: + Enabled: false + Lint/UselessAssignment: + Exclude: + - "app/views/**/*" + +EnableDefaultLinters: true diff --git a/lib/generators/templates/lint/erblint.rake b/lib/generators/templates/lint/erblint.rake new file mode 100644 index 000000000..804f572fc --- /dev/null +++ b/lib/generators/templates/lint/erblint.rake @@ -0,0 +1,47 @@ +module ERBLint + module RakeSupport + # Allow command line flags set in STANDARDOPTS (like MiniTest's TESTOPTS) + def self.argvify + if ENV["ERBLINTOPTS"] + ENV["ERBLINTOPTS"].split(/\s+/) + else + [] + end + end + + # DELETE THIS FILE AFTER MERGE: + # + # * https://github.com/Shopify/better-html/pull/95 + # + def self.backport! + BetterHtml::TestHelper::SafeErb::AllowedScriptType::VALID_JAVASCRIPT_TAG_TYPES.push("module") + end + end +end + +desc "Lint templates with erb_lint" +task "erblint" do + require "erb_lint/cli" + require "erblint-github/linters" + + ERBLint::RakeSupport.backport! + + cli = ERBLint::CLI.new + success = cli.run(ERBLint::RakeSupport.argvify + ["--lint-all", "--format=compact"]) + fail unless success +end + +desc "Lint and automatically fix templates with erb_lint" +task "erblint:autocorrect" do + require "erb_lint/cli" + require "erblint-github/linters" + + ERBLint::RakeSupport.backport! + + cli = ERBLint::CLI.new + success = cli.run(ERBLint::RakeSupport.argvify + ["--lint-all", "--autocorrect"]) + fail unless success +end + +task "standard" => "erblint" +task "standard:fix" => "erblint:autocorrect" diff --git a/lib/generators/templates/lint/eslintrc.json b/lib/generators/templates/lint/eslintrc.json new file mode 100644 index 000000000..48e5b597f --- /dev/null +++ b/lib/generators/templates/lint/eslintrc.json @@ -0,0 +1,7 @@ +{ + "extends": ["@thoughtbot/eslint-config/prettier"], + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + } +} diff --git a/lib/generators/templates/lint/prettierrc b/lib/generators/templates/lint/prettierrc new file mode 100644 index 000000000..1e8fc8e24 --- /dev/null +++ b/lib/generators/templates/lint/prettierrc @@ -0,0 +1,11 @@ +{ + "singleQuote": true, + "overrides": [ + { + "files": ["**/*.css", "**/*.scss", "**/*.html"], + "options": { + "singleQuote": false + } + } + ] +} diff --git a/lib/generators/templates/lint/rubocop.yml.tt b/lib/generators/templates/lint/rubocop.yml.tt new file mode 100644 index 000000000..d6983a491 --- /dev/null +++ b/lib/generators/templates/lint/rubocop.yml.tt @@ -0,0 +1,7 @@ +AllCops: + TargetRubyVersion: <%= RUBY_VERSION %> + +require: standard + +inherit_gem: + standard: config/base.yml diff --git a/lib/generators/templates/lint/stylelintrc.json b/lib/generators/templates/lint/stylelintrc.json new file mode 100644 index 000000000..3171f405a --- /dev/null +++ b/lib/generators/templates/lint/stylelintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@thoughtbot/stylelint-config" +} diff --git a/test/generators/suspenders/lint_generator_test.rb b/test/generators/suspenders/lint_generator_test.rb new file mode 100644 index 000000000..709732594 --- /dev/null +++ b/test/generators/suspenders/lint_generator_test.rb @@ -0,0 +1,324 @@ +require "test_helper" +require "generators/suspenders/lint_generator" + +module Suspenders + module Generators + class LintGeneratorTest < Rails::Generators::TestCase + include Suspenders::TestHelpers + + tests Suspenders::Generators::LintGenerator + destination Rails.root + setup :prepare_destination + teardown :restore_destination + + test "installs dependencies" do + capture(:stderr) do + output = run_generator + + assert_match(/yarn add stylelint@\^15\.10\.1 eslint @thoughtbot\/stylelint-config@3\.0\.0 @thoughtbot\/eslint-config npm-run-all prettier --dev/, output) + end + end + + test "installs gems" do + capture(:stderr) do + expected_gemfile = <<~TEXT + group :development, :test do + gem "better_html", require: false + gem "erb_lint", require: false + gem "erblint-github", require: false + gem "standard" + end + TEXT + + output = run_generator + + assert_match(/bundle install/, output) + assert_file app_root "Gemfile" do |file| + assert_match expected_gemfile, file + end + end + end + + test "configures stylelint" do + expected_content = <<~TEXT + { + "extends": "@thoughtbot/stylelint-config" + } + TEXT + + capture(:stderr) { run_generator } + + assert_file app_root(".stylelintrc.json") do |file| + assert_equal expected_content, file + end + end + + test "configures eslint" do + expected_content = <<~JSON + { + "extends": ["@thoughtbot/eslint-config/prettier"], + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + } + } + JSON + + capture(:stderr) { run_generator } + + assert_file app_root(".eslintrc.json") do |file| + assert_equal expected_content, file + end + end + + test "configures prettier" do + expected_content = <<~JSON + { + "singleQuote": true, + "overrides": [ + { + "files": ["**/*.css", "**/*.scss", "**/*.html"], + "options": { + "singleQuote": false + } + } + ] + } + JSON + + capture(:stderr) { run_generator } + + assert_file app_root(".prettierrc") do |file| + assert_equal expected_content, file + end + end + + test "configures erb-lint" do + capture(:stderr) { run_generator } + + assert_file app_root(".erb-lint.yml") + assert_file app_root("config/better_html.yml") + assert_file app_root("config/initializers/better_html.rb") + assert_file app_root("lib/tasks/erblint.rake") + end + + test "erb-lint.yml configuration" do + expected_content = <<~YAML + --- + glob: "app/views/**/*.{html,turbo_stream}{+*,}.erb" + + linters: + AllowedScriptType: + enabled: true + allowed_types: + - "module" + - "text/javascript" + ErbSafety: + enabled: true + better_html_config: "config/better_html.yml" + GitHub::Accessibility::AvoidBothDisabledAndAriaDisabledCounter: + enabled: true + GitHub::Accessibility::AvoidGenericLinkTextCounter: + enabled: true + GitHub::Accessibility::DisabledAttributeCounter: + enabled: true + GitHub::Accessibility::IframeHasTitleCounter: + enabled: true + GitHub::Accessibility::ImageHasAltCounter: + enabled: true + GitHub::Accessibility::LandmarkHasLabelCounter: + enabled: true + GitHub::Accessibility::LinkHasHrefCounter: + enabled: true + GitHub::Accessibility::NestedInteractiveElementsCounter: + enabled: true + GitHub::Accessibility::NoAriaLabelMisuseCounter: + enabled: true + GitHub::Accessibility::NoPositiveTabIndexCounter: + enabled: true + GitHub::Accessibility::NoRedundantImageAltCounter: + enabled: true + GitHub::Accessibility::NoTitleAttributeCounter: + enabled: true + GitHub::Accessibility::SvgHasAccessibleTextCounter: + enabled: true + Rubocop: + enabled: true + rubocop_config: + inherit_from: + - .rubocop.yml + + Lint/EmptyBlock: + Enabled: false + Layout/InitialIndentation: + Enabled: false + Layout/TrailingEmptyLines: + Enabled: false + Layout/TrailingWhitespace: + Enabled: false + Layout/LeadingEmptyLines: + Enabled: false + Style/FrozenStringLiteralComment: + Enabled: false + Style/MultilineTernaryOperator: + Enabled: false + Lint/UselessAssignment: + Exclude: + - "app/views/**/*" + + EnableDefaultLinters: true + YAML + + capture(:stderr) { run_generator } + + assert_file app_root(".erb-lint.yml") do |file| + assert_equal expected_content, file + end + end + + test "better html configuration" do + expected_content = <<~RUBY + Rails.configuration.to_prepare do + if Rails.env.test? + require "better_html" + + BetterHtml.config = BetterHtml::Config.new(Rails.configuration.x.better_html) + + BetterHtml.config.template_exclusion_filter = proc { |filename| !filename.start_with?(Rails.root.to_s) } + end + end + RUBY + + capture(:stderr) { run_generator } + + assert_file app_root("config/initializers/better_html.rb") do |file| + assert_equal expected_content, file + end + end + + test "generates erb-lint tests" do + with_test_suite :minitest do + capture(:stderr) { run_generator } + + assert_file app_root("test/views/better_html_test.rb") + assert_no_file app_root("spec/views/better_html_spec.rb") + end + end + + test "generates erb-lint specs" do + with_test_suite :rspec do + capture(:stderr) { run_generator } + + assert_file app_root("spec/views/better_html_spec.rb") + assert_no_file app_root("test/views/better_html_test.rb") + end + end + + test "generates .rubocop.yml" do + expected_content = <<~YAML + AllCops: + TargetRubyVersion: #{RUBY_VERSION} + + require: standard + + inherit_gem: + standard: config/base.yml + YAML + + capture(:stderr) { run_generator } + + assert_file app_root(".rubocop.yml") do |file| + assert_equal expected_content, file + end + end + + test "updates package.json" do + touch "package.json", content: package_json + + capture(:stderr) { run_generator } + + assert_file "package.json" do |file| + assert_equal expected_package_json, file + end + end + + test "updates package.json if script key does not exist" do + touch "package.json", content: package_json(empty: true) + + capture(:stderr) { run_generator } + + assert_file "package.json" do |file| + assert_equal expected_package_json, file + end + end + + test "description" do + desc = "Creates a holistic linting solution that covers JavaScript, CSS, Ruby and ERB." + + assert_equal desc, generator_class.desc + end + + test "fixes violations" do + capture(:stderr) do + output = run_generator + + assert_match(/yarn run fix:prettier/, output) + assert_match(/bundle exec rake standard:fix_unsafely/, output) + end + end + + private + + def prepare_destination + touch "Gemfile" + touch "package.json", content: package_json(empty: true) + end + + def restore_destination + remove_file_if_exists "Gemfile" + remove_file_if_exists ".stylelintrc.json" + remove_file_if_exists ".eslintrc.json" + remove_file_if_exists ".prettierrc" + remove_file_if_exists "package.json" + remove_file_if_exists ".erb-lint.yml" + remove_file_if_exists "config/better_html.yml" + remove_file_if_exists "config/initializers/better_html.rb" + remove_file_if_exists ".rubocop.yml" + remove_file_if_exists "package.json", root: true + remove_file_if_exists "yarn.lock", root: true + remove_dir_if_exists "lib/tasks" + remove_dir_if_exists "test" + remove_dir_if_exists "spec" + end + + def package_json(empty: false) + if empty + <<~JSON.chomp + { + } + JSON + else + <<~JSON.chomp + { + "scripts": {} + } + JSON + end + end + + def expected_package_json + <<~JSON.chomp + { + "scripts": { + "lint": "run-p lint:eslint lint:stylelint lint:prettier", + "lint:eslint": "eslint --max-warnings=0 --no-error-on-unmatched-pattern 'app/javascript/**/*.js'", + "lint:stylelint": "stylelint 'app/assets/stylesheets/**/*.css'", + "lint:prettier": "prettier --check '**/*' --ignore-unknown", + "fix:prettier": "prettier --write '**/*' --ignore-unknown" + } + } + JSON + end + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0495cd10b..b0670b416 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -38,10 +38,16 @@ def mkdir(dir) FileUtils.mkdir path end - def touch(file) + # TODO: Update existing tests to use the content: option + def touch(file, **options) + content = options[:content] path = app_root file FileUtils.touch path + + if content + File.write app_root(path), content + end end def within_api_only_app(**options, &block) @@ -76,6 +82,24 @@ class Application < Rails::Application restore_file "config/application.rb" end + # TODO: Refactor existing tests to use this + def with_test_suite(test_suite, &block) + case test_suite + when :minitest + mkdir "test" + when :rspec + mkdir "spec" + touch "spec/spec_helper.rb" + else + raise ArgumentError, "unknown test suite: #{test_suite.inspect}" + end + + yield + ensure + remove_dir_if_exists "test" + remove_dir_if_exists "spec" + end + def backup_file(file) FileUtils.copy app_root(file), app_root("#{file}.bak") end